package projectworkflow import ( "context" "errors" "fmt" "strings" "sync" "time" ) // ============================================================================= // 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态) // ============================================================================= type memStore struct { mu sync.RWMutex projects map[int64]*Project members map[int64]map[int64]struct{} // projectID -> set of active member userIDs byClientKey map[string]int64 // creatorID|clientKey -> projectID workflows map[int64]map[string]*ProjectWorkflow runs map[string]*WorkflowRun bindings map[int64]memBinding audits []*WorkflowActivationAudit lineages []*DocumentLineage nextProjID int64 nextWFID int64 nextRunID int64 nextLineageID int64 } func newMemStore() *memStore { m := &memStore{ projects: make(map[int64]*Project), members: make(map[int64]map[int64]struct{}), byClientKey: make(map[string]int64), workflows: make(map[int64]map[string]*ProjectWorkflow), runs: make(map[string]*WorkflowRun), nextProjID: 1000, nextWFID: 1, nextRunID: 1, nextLineageID: 1, } now := time.Now() m.projects[1] = &Project{ ID: 1, Code: "PROJ-001", Name: "医用超声高频能量手术集成系统", CustomerName: "北医三院创新转化中心", DeviceType: "III类医疗器械", IntendedUse: "用于外科手术中软组织的切割与血管止血", TargetMarket: "中国 NMPA + 欧盟 CE MDR", CurrentStage: "stage_1_spec", Environment: "dev", Status: "active", CreatorUserID: 1, ActiveWorkflowKey: "main", WorkflowSelectionVersion: 1, CreatedAt: now, UpdatedAt: now, } m.projects[5201] = &Project{ ID: 5201, Code: "CUSA-NEUROPULSE", Name: "神经外科双频超声吸引系统 (CUSA-NeuroPulse)", CustomerName: "天坛医院神经外科研究所", DeviceType: "III类医疗器械", IntendedUse: "用于神经外科颅内脑肿瘤精准超声粉碎吸引与止血", TargetMarket: "中国 NMPA + 欧盟 CE MDR", CurrentStage: "stage_1_spec", Environment: "dev", Status: "active", CreatorUserID: 1, ActiveWorkflowKey: "main", WorkflowSelectionVersion: 1, CreatedAt: now, UpdatedAt: now, } return m } func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, error) { m.mu.Lock() defer m.mu.Unlock() m.nextProjID++ p.ID = m.nextProjID if p.Status == "" { p.Status = "active" } if p.LifecycleStatus == "" { p.LifecycleStatus = "planning" } if p.WorkflowSelectionVersion <= 0 { p.WorkflowSelectionVersion = 1 } now := time.Now() p.CreatedAt = now p.UpdatedAt = now cpy := *p m.projects[p.ID] = &cpy if p.CreatorUserID > 0 { if m.members[p.ID] == nil { m.members[p.ID] = make(map[int64]struct{}) } m.members[p.ID][p.CreatorUserID] = struct{}{} if key := strings.TrimSpace(p.ClientKey); key != "" { m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID } } return p, nil } // AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN). func (m *memStore) AddProjectMember(projectID, userID int64) { if projectID <= 0 || userID <= 0 { return } m.mu.Lock() defer m.mu.Unlock() if m.members[projectID] == nil { m.members[projectID] = make(map[int64]struct{}) } m.members[projectID][userID] = struct{}{} } func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) { clientKey = strings.TrimSpace(clientKey) if creatorUserID <= 0 || clientKey == "" { return nil, ErrProjectNotFound } m.mu.RLock() defer m.mu.RUnlock() id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)] if !ok { return nil, ErrProjectNotFound } p, ok := m.projects[id] if !ok { return nil, ErrProjectNotFound } cpy := *p return &cpy, nil } func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) { name = strings.TrimSpace(name) if creatorUserID <= 0 || name == "" { return nil, ErrProjectNotFound } m.mu.RLock() defer m.mu.RUnlock() want := strings.ToLower(name) for _, p := range m.projects { if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want { cpy := *p return &cpy, nil } } return nil, ErrProjectNotFound } func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { name = strings.TrimSpace(name) if name == "" { return nil, ErrProjectNotFound } m.mu.RLock() defer m.mu.RUnlock() want := strings.ToLower(name) for _, p := range m.projects { if strings.EqualFold(p.Status, "demo_retired") { continue } if strings.ToLower(strings.TrimSpace(p.Name)) == want { cpy := *p return &cpy, nil } } return nil, ErrProjectNotFound } func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { m.mu.RLock() defer m.mu.RUnlock() p, ok := m.projects[id] if !ok { return nil, ErrProjectNotFound } cpy := *p return &cpy, nil } func (m *memStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) { m.mu.RLock() defer m.mu.RUnlock() for _, p := range m.projects { if p.Code == code { cpy := *p return &cpy, nil } } return nil, ErrProjectNotFound } func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { m.mu.RLock() defer m.mu.RUnlock() var list []*Project for _, p := range m.projects { if p.CreatorUserID == userID { cpy := *p list = append(list, &cpy) continue } if members, ok := m.members[p.ID]; ok { if _, isMember := members[userID]; isMember { cpy := *p list = append(list, &cpy) } } } return list, nil } func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) { m.mu.RLock() defer m.mu.RUnlock() list := make([]*Project, 0, len(m.projects)) for _, p := range m.projects { cpy := *p list = append(list, &cpy) } return list, nil } func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { m.mu.RLock() defer m.mu.RUnlock() wfs, ok := m.workflows[projectID] if !ok { return nil, nil } var list []*ProjectWorkflow for _, pw := range wfs { cpy := *pw list = append(list, &cpy) } return list, nil } func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { m.mu.Lock() defer m.mu.Unlock() if _, ok := m.workflows[projectID]; !ok { m.workflows[projectID] = make(map[string]*ProjectWorkflow) } proj := m.projects[projectID] activeKey := "" if proj != nil { activeKey = proj.ActiveWorkflowKey } for _, key := range defaultKeys { if _, exists := m.workflows[projectID][key]; !exists { m.nextWFID++ status := "ready" if key == activeKey { status = "active" } now := time.Now() m.workflows[projectID][key] = &ProjectWorkflow{ ID: m.nextWFID, ProjectID: projectID, WorkflowKey: key, Status: status, Enabled: true, CreatedAt: now, UpdatedAt: now, } } } return nil } func (m *memStore) UpdateActiveWorkflow( ctx context.Context, projectID int64, toWorkflowKey string, expectedVersion int, changedBy int64, reason string, ) (*Project, *WorkflowActivationAudit, error) { m.mu.Lock() defer m.mu.Unlock() p, ok := m.projects[projectID] if !ok { return nil, nil, ErrProjectNotFound } // 权限检查 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换) isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020) if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy && changedBy != 1 && !isReviewOrDemo { return nil, nil, ErrForbiddenNotCreator } // 乐观锁检查 if expectedVersion > 0 && expectedVersion != p.WorkflowSelectionVersion { return nil, nil, ErrConcurrentConflict } // 幂等 if p.ActiveWorkflowKey == toWorkflowKey { cpy := *p return &cpy, nil, nil } curVersion := p.WorkflowSelectionVersion nextVersion := curVersion + 1 now := time.Now() fromKey := p.ActiveWorkflowKey p.ActiveWorkflowKey = toWorkflowKey p.WorkflowSelectionVersion = nextVersion p.ActiveWorkflowChangedAt = &now p.ActiveWorkflowChangedBy = &changedBy p.UpdatedAt = now // 更新挂载的工作流状态 if wfs, ok := m.workflows[projectID]; ok { for k, wf := range wfs { if k == toWorkflowKey { wf.Status = "active" wf.UpdatedAt = now } else if wf.Status == "active" { wf.Status = "ready" wf.UpdatedAt = now } } } audit := &WorkflowActivationAudit{ ID: int64(len(m.audits) + 1), ProjectID: projectID, FromWorkflowKey: fromKey, ToWorkflowKey: toWorkflowKey, ChangedBy: changedBy, VersionBefore: curVersion, VersionAfter: nextVersion, Reason: reason, CreatedAt: now, } m.audits = append(m.audits, audit) cpy := *p return &cpy, audit, nil } func (m *memStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { m.mu.Lock() defer m.mu.Unlock() m.nextRunID++ run.ID = m.nextRunID now := time.Now() run.CreatedAt = now run.StartedAt = &now cpy := *run m.runs[run.RunID] = &cpy return run, nil } func (m *memStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error { m.mu.Lock() defer m.mu.Unlock() run, ok := m.runs[runID] if !ok { return errors.New("workflow run not found") } now := time.Now() run.Status = status if outputDocID != "" { run.OutputDocID = outputDocID } if summary != "" { run.OutputSummary = summary } run.ErrorMessage = errMsg run.FinishedAt = &now return nil } func (m *memStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { m.mu.RLock() defer m.mu.RUnlock() run, ok := m.runs[runID] if !ok { return nil, errors.New("workflow run not found") } cpy := *run return &cpy, nil } func (m *memStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { m.mu.RLock() defer m.mu.RUnlock() var list []*WorkflowRun for _, r := range m.runs { if r.ProjectID == projectID && (workflowKey == "" || r.WorkflowKey == workflowKey) { cpy := *r list = append(list, &cpy) } } return list, nil } func (m *memStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { m.mu.Lock() defer m.mu.Unlock() m.nextLineageID++ lineage.ID = m.nextLineageID lineage.CreatedAt = time.Now() cpy := *lineage m.lineages = append(m.lineages, &cpy) return nil } func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { m.mu.RLock() defer m.mu.RUnlock() var list []*DocumentLineage for _, l := range m.lineages { if l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { cpy := *l list = append(list, &cpy) } } return list, nil } func (m *memStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { m.mu.RLock() defer m.mu.RUnlock() var list []*DocumentLineage for _, l := range m.lineages { if l.ProjectID == projectID && l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { cpy := *l list = append(list, &cpy) } } return list, nil } func (m *memStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { m.mu.RLock() defer m.mu.RUnlock() var list []*DocumentLineage for _, l := range m.lineages { if l.UpstreamDocID == upstreamDocID { cpy := *l list = append(list, &cpy) } } return list, nil } func (m *memStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { m.mu.Lock() defer m.mu.Unlock() now := time.Now() var updated []*DocumentLineage for _, l := range m.lineages { if l.UpstreamDocID == upstreamDocID && !l.IsStale { l.IsStale = true l.StaleReason = staleReason l.StaleDetectedAt = &now cpy := *l updated = append(updated, &cpy) } } return updated, nil } func (m *memStore) SaveProjectSourceBinding(ctx context.Context, projectID int64, repoPath, permissionScope string, createdBy int64) error { m.mu.Lock() defer m.mu.Unlock() if m.bindings == nil { m.bindings = make(map[int64]memBinding) } m.bindings[projectID] = memBinding{RepoPath: repoPath, PermissionScope: permissionScope, CreatedBy: createdBy} return nil } type memBinding struct { RepoPath string PermissionScope string CreatedBy int64 }