package projectworkflow import ( "context" "crypto/rand" "encoding/hex" "fmt" "strings" "time" "chat-agent-system/internal/auth" ) // Service 项目多工作流与血缘领域服务 type Service struct { store Store catalog Catalog // RepoAllowlistChecker unlocks execution_mode=controlled_patch at // creation when the bound repo path passes the admin allowlist (SWBOOT-10). // nil keeps the fail-closed rejection. RepoAllowlistChecker func(repoPath string) bool } // NewService 构造项目工作流服务 func NewService(store Store, catalog Catalog) *Service { if catalog == nil { catalog = NewDefaultCatalog() } return &Service{ store: store, catalog: catalog, } } // Catalog 获取静态工作流规范目录 func (s *Service) Catalog() Catalog { return s.catalog } // Store 获取持久化存储接口 func (s *Service) Store() Store { return s.store } // CreateProject 创建新项目,严格从认证令牌注入权威创建人,并自动初始化挂载工作流 func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in CreateProjectInput) (*Project, error) { if user == nil || user.ID <= 0 { return nil, ErrUnauthenticated } name := strings.TrimSpace(in.Name) if name == "" { return nil, fmt.Errorf("%w: 项目名称为必填项", ErrInvalidInput) } clientKey := strings.TrimSpace(in.ClientKey) if clientKey != "" { existing, err := s.store.GetProjectByClientKey(ctx, user.ID, clientKey) if err == nil && existing != nil { return existing, nil } if err != nil && err != ErrProjectNotFound { return nil, err } } if existing, err := s.store.FindProjectByName(ctx, name); err == nil && existing != nil { return nil, fmt.Errorf("%w: 项目名「%s」已被占用,请更换名称后重试", ErrDuplicateProjectName, name) } else if err != nil && err != ErrProjectNotFound { return nil, err } code := strings.TrimSpace(in.Code) if code == "" { code = generateProjectCode(name) } initialWorkflow := strings.TrimSpace(in.InitialWorkflowKey) if initialWorkflow != "" { if _, ok := s.catalog.Get(initialWorkflow); !ok { return nil, fmt.Errorf("%w: 初始工作流 %s 不存在", ErrWorkflowNotFound, initialWorkflow) } } stage := "V0.1_triage" if strings.TrimSpace(in.ProductDomain) == "non_medical_software" { stage = "S0_boundary" if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" { repoPath := strings.TrimSpace(in.RepoPath) if repoPath == "" || s.RepoAllowlistChecker == nil || !s.RepoAllowlistChecker(repoPath) { return nil, fmt.Errorf("%w: controlled_patch requires an allowlisted repo binding (SWBOOT-10)", ErrInvalidInput) } } } p := &Project{ Code: code, Name: name, CustomerName: in.CustomerName, DeviceType: in.DeviceType, IntendedUse: in.IntendedUse, TargetMarket: in.TargetMarket, ProductDomain: strings.TrimSpace(in.ProductDomain), ProjectTypeKey: strings.TrimSpace(in.ProjectTypeKey), ComplianceProfileKey: strings.TrimSpace(in.ComplianceProfileKey), GovernanceProfile: strings.TrimSpace(in.GovernanceProfile), TeamMode: strings.TrimSpace(in.TeamMode), ExecutionMode: strings.TrimSpace(in.ExecutionMode), DataSensitivity: strings.TrimSpace(in.DataSensitivity), NetworkPolicy: strings.TrimSpace(in.NetworkPolicy), BudgetClass: strings.TrimSpace(in.BudgetClass), ProblemStatement: strings.TrimSpace(in.ProblemStatement), TargetUsers: strings.TrimSpace(in.TargetUsers), TargetOutcome: strings.TrimSpace(in.TargetOutcome), ProductShape: strings.TrimSpace(in.ProductShape), CurrentStage: stage, Environment: "dev", Status: "active", CreatorUserID: user.ID, LifecycleStatus: "planning", ActiveWorkflowKey: "", WorkflowSelectionVersion: 1, WorkflowPlanRevision: 0, ClientKey: clientKey, } // initialWorkflow is catalog-validated only; activation stays on the settings path. created, err := s.store.CreateProject(ctx, p) if err != nil { return nil, err } // SWBOOT-10: persist the repo binding for controlled_patch projects so // the executor can verify it on every later execution. if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" && strings.TrimSpace(in.RepoPath) != "" { if err := s.store.SaveProjectSourceBinding(ctx, created.ID, strings.TrimSpace(in.RepoPath), "controlled_patch", user.ID); err != nil { return nil, err } } // 按产品域挂载目录工作流,避免软件项目克隆医疗 key。 allKeys := WorkflowKeysForProductDomain(in.ProductDomain) if len(allKeys) == 0 { for _, def := range s.catalog.List() { allKeys = append(allKeys, def.Key) } } if err := s.store.EnsureProjectWorkflows(ctx, created.ID, allKeys); err != nil { return nil, fmt.Errorf("initialize project workflows failed: %w", err) } return created, nil } // GetProject 根据项目 ID 获取项目详情 func (s *Service) GetProject(ctx context.Context, projectID int64, userID int64) (*Project, error) { if projectID <= 0 { return nil, ErrProjectNotFound } p, err := s.store.GetProjectByID(ctx, projectID) if err != nil { return nil, err } return p, nil } // GetProjectByCode 根据项目唯一编码获取项目详情 func (s *Service) GetProjectByCode(ctx context.Context, code string) (*Project, error) { return s.store.GetProjectByCode(ctx, code) } // ListProjects 查询指定用户可见的项目列表 func (s *Service) ListProjects(ctx context.Context, userID int64) ([]*Project, error) { return s.store.ListProjectsForUser(ctx, userID) } // ListAllProjects 查询全部项目,仅供已完成系统管理员鉴权的调用方使用。 func (s *Service) ListAllProjects(ctx context.Context) ([]*Project, error) { return s.store.ListAllProjects(ctx) } // IsProjectCreator 判断用户是否为指定项目的权威创建人 func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) { p, err := s.store.GetProjectByID(ctx, projectID) if err != nil { return false, err } return p.CreatorUserID == userID, nil } // ListProjectWorkflows 查询项目已挂载的工作流清单 func (s *Service) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { domain := "" if p, err := s.store.GetProjectByID(ctx, projectID); err == nil && p != nil { domain = p.ProductDomain } allKeys := WorkflowKeysForProductDomain(domain) if len(allKeys) == 0 { for _, def := range s.catalog.List() { allKeys = append(allKeys, def.Key) } } _ = s.store.EnsureProjectWorkflows(ctx, projectID, allKeys) return s.store.ListProjectWorkflows(ctx, projectID) } // ActivateWorkflow 原子切换项目当前激活的工作流焦点(仅限创建人) func (s *Service) ActivateWorkflow( ctx context.Context, projectID int64, userID int64, in ActivateWorkflowInput, ) (*ActivateWorkflowResult, error) { if userID <= 0 { return nil, ErrUnauthenticated } targetKey := strings.TrimSpace(in.WorkflowKey) if targetKey == "" { return nil, fmt.Errorf("%w: target workflow_key cannot be empty", ErrInvalidInput) } def, ok := s.catalog.Get(targetKey) if !ok || !def.Enabled { return nil, fmt.Errorf("%w: workflow '%s' is not supported or disabled", ErrWorkflowNotFound, targetKey) } updatedProject, audit, err := s.store.UpdateActiveWorkflow( ctx, projectID, targetKey, in.ExpectedVersion, userID, in.Reason, ) if err != nil { return nil, err } now := time.Now() if audit != nil { now = audit.CreatedAt } else if updatedProject.ActiveWorkflowChangedAt != nil { now = *updatedProject.ActiveWorkflowChangedAt } return &ActivateWorkflowResult{ ProjectID: updatedProject.ID, ActiveWorkflowKey: updatedProject.ActiveWorkflowKey, Version: updatedProject.WorkflowSelectionVersion, ChangedAt: now, ChangedBy: userID, }, nil } // CreateWorkflowRun 创建工作流运行实例 func (s *Service) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { if run.RunID == "" { run.RunID = generateRunID(run.WorkflowKey) } if run.Status == "" { run.Status = "running" } return s.store.CreateWorkflowRun(ctx, run) } // UpdateWorkflowRun 结束/更新工作流运行实例 func (s *Service) UpdateWorkflowRun( ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string, ) error { return s.store.UpdateWorkflowRun(ctx, runID, status, outputDocID, summary, errMsg) } // GetWorkflowRun 获取运行记录详情 func (s *Service) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { return s.store.GetWorkflowRun(ctx, runID) } // ListWorkflowRuns 列出项目的运行历史 func (s *Service) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { return s.store.ListWorkflowRuns(ctx, projectID, workflowKey, limit) } // RecordLineage 记录文档版本血缘 func (s *Service) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { return s.store.RecordLineage(ctx, lineage) } // GetLineageForDownstream 查询下游文档所依赖的上游版本信息。 // 内部失效传播仍用此方法;HTTP 读取必须走 GetLineageForProjectDownstream。 func (s *Service) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { return s.store.GetLineageForDownstream(ctx, docID, version) } // GetLineageForProjectDownstream 只返回 URL 项目内的血缘边。 func (s *Service) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { if projectID <= 0 || strings.TrimSpace(docID) == "" { return nil, nil } return s.store.GetLineageForProjectDownstream(ctx, projectID, strings.TrimSpace(docID), version) } // CheckAndPropagateStale 当上游文档更新且内容哈希变化时,通知并将关联下游文档标记为 stale func (s *Service) CheckAndPropagateStale( ctx context.Context, upstreamDocID string, newSHA256 string, reason string, ) ([]*DocumentLineage, error) { lineages, err := s.store.GetLineagesForUpstream(ctx, upstreamDocID) if err != nil { return nil, err } needsStale := false for _, l := range lineages { if l.UpstreamSHA256 != "" && l.UpstreamSHA256 != newSHA256 && !l.IsStale { needsStale = true break } } if !needsStale { return nil, nil } if reason == "" { reason = fmt.Sprintf("上游文档 %s 生成了新版本,内容已发生实质变化", upstreamDocID) } return s.store.MarkDownstreamStale(ctx, upstreamDocID, reason) } // 辅助方法:生成项目短代码(按 rune 截断,避免切断 UTF-8 多字节字符导致 PG 插入失败) func generateProjectCode(name string) string { b := make([]byte, 4) _, _ = rand.Read(b) hash := hex.EncodeToString(b) cleanName := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) runes := []rune(cleanName) if len(runes) > 12 { runes = runes[:12] cleanName = string(runes) } if cleanName == "" { cleanName = "proj" } return fmt.Sprintf("%s-%s", cleanName, hash) } // 辅助方法:生成运行实例 ID func generateRunID(workflowKey string) string { b := make([]byte, 6) _, _ = rand.Read(b) return fmt.Sprintf("run_%s_%s", workflowKey, hex.EncodeToString(b)) }