Validate the catalog key only and stop writing lifecycle=active so activation stays on the settings DutyMode path. Co-authored-by: Cursor <cursoragent@cursor.com>
293 lines
8.2 KiB
Go
293 lines
8.2 KiB
Go
package projectworkflow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/auth"
|
|
)
|
|
|
|
// Service 项目多工作流与血缘领域服务
|
|
type Service struct {
|
|
store Store
|
|
catalog Catalog
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
p := &Project{
|
|
Code: code,
|
|
Name: name,
|
|
CustomerName: in.CustomerName,
|
|
DeviceType: in.DeviceType,
|
|
IntendedUse: in.IntendedUse,
|
|
TargetMarket: in.TargetMarket,
|
|
CurrentStage: "V0.1_triage",
|
|
Environment: "dev",
|
|
Status: "active",
|
|
CreatorUserID: user.ID,
|
|
LifecycleStatus: "planning",
|
|
ActiveWorkflowKey: "",
|
|
WorkflowSelectionVersion: 1,
|
|
WorkflowPlanRevision: 0,
|
|
}
|
|
// initialWorkflow is catalog-validated only; activation stays on the settings path.
|
|
|
|
created, err := s.store.CreateProject(ctx, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 自动挂载目录中的工作流实例
|
|
var allKeys []string
|
|
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) {
|
|
// 确保默认工作流已初始化
|
|
var allKeys []string
|
|
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 查询下游文档所依赖的上游版本信息
|
|
func (s *Service) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
|
|
return s.store.GetLineageForDownstream(ctx, 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))
|
|
}
|