feat(workflow): Phase 1 & 2 数据库迁移、项目创建人权威化与多工作流控制面
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
-- 2026-09-11: 项目多工作流体系、创建人权威化与文档血缘追溯
|
||||
-- 支持:项目创建人权限、三层工作流模型、工作流原子激活与审计、智能产品经理与测试经理文档血缘追踪
|
||||
|
||||
-- 1. 为 projects 表添加创建人与工作流激活控制字段
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS creator_user_id BIGINT REFERENCES users(id);
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS active_workflow_key TEXT NOT NULL DEFAULT 'main';
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS workflow_selection_version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS active_workflow_changed_at TIMESTAMPTZ;
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS active_workflow_changed_by BIGINT REFERENCES users(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_creator ON projects(creator_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_active_workflow ON projects(active_workflow_key);
|
||||
|
||||
-- 2. 项目工作流实例表 (挂载到项目的可用工作流实例与局部状态)
|
||||
CREATE TABLE IF NOT EXISTS project_workflows (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
workflow_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ready', -- ready | active | disabled
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_project_workflow UNIQUE(project_id, workflow_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_workflows_pid ON project_workflows(project_id);
|
||||
|
||||
-- 3. 工作流执行运行实例表 (追踪执行历史、步骤与产出文档)
|
||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
workflow_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | running | completed | failed | cancelled
|
||||
started_by BIGINT REFERENCES users(id),
|
||||
stage_key TEXT NOT NULL DEFAULT '',
|
||||
task_key TEXT NOT NULL DEFAULT '',
|
||||
input_manifest JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
output_doc_id TEXT NOT NULL DEFAULT '',
|
||||
output_summary TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_pid ON workflow_runs(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
||||
|
||||
-- 4. 工作流激活切换审计日志 (只追加写入,记录切换人、版本变化与原因)
|
||||
CREATE TABLE IF NOT EXISTS workflow_activation_audits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
from_workflow_key TEXT NOT NULL DEFAULT '',
|
||||
to_workflow_key TEXT NOT NULL,
|
||||
changed_by BIGINT REFERENCES users(id),
|
||||
version_before INT NOT NULL,
|
||||
version_after INT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_activation_audits_pid ON workflow_activation_audits(project_id);
|
||||
|
||||
-- 5. 文档血缘与 Stale 追溯表 (上下游依赖、版本快照哈希与过期标记)
|
||||
CREATE TABLE IF NOT EXISTS document_lineage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
downstream_doc_id TEXT NOT NULL,
|
||||
downstream_version INT NOT NULL DEFAULT 1,
|
||||
upstream_doc_id TEXT NOT NULL,
|
||||
upstream_version INT NOT NULL DEFAULT 1,
|
||||
upstream_sha256 TEXT NOT NULL DEFAULT '',
|
||||
is_stale BOOLEAN NOT NULL DEFAULT false,
|
||||
stale_reason TEXT NOT NULL DEFAULT '',
|
||||
stale_detected_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_doc_lineage UNIQUE(downstream_doc_id, downstream_version, upstream_doc_id, upstream_version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_lineage_downstream ON document_lineage(downstream_doc_id, downstream_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_lineage_upstream ON document_lineage(upstream_doc_id, upstream_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_lineage_stale ON document_lineage(is_stale);
|
||||
|
||||
-- 6. 历史数据回填:为缺失创建人的历史项目回填默认创建人 (关联首个会话或设为管理员 1)
|
||||
UPDATE projects p
|
||||
SET creator_user_id = COALESCE(
|
||||
(SELECT cs.user_id FROM chat_sessions cs WHERE cs.project_id = p.id AND cs.user_id IS NOT NULL ORDER BY cs.id ASC LIMIT 1),
|
||||
1
|
||||
)
|
||||
WHERE p.creator_user_id IS NULL;
|
||||
|
||||
-- 7. 历史数据回填:为已有项目自动挂载三个标准工作流实例 (main, product_manager, test_manager)
|
||||
INSERT INTO project_workflows (project_id, workflow_key, status, enabled)
|
||||
SELECT p.id, w.key,
|
||||
CASE WHEN w.key = COALESCE(p.active_workflow_key, 'main') THEN 'active' ELSE 'ready' END,
|
||||
true
|
||||
FROM projects p
|
||||
CROSS JOIN (VALUES ('main'), ('product_manager'), ('test_manager')) AS w(key)
|
||||
ON CONFLICT (project_id, workflow_key) DO NOTHING;
|
||||
@@ -207,6 +207,17 @@ func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccoun
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据用户 ID 直接查询用户信息 (安全过滤密码哈希)
|
||||
func (s *Service) GetUserByID(ctx context.Context, userID int64) (*UserAccount, error) {
|
||||
user, err := s.store.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cpy := *user
|
||||
cpy.PasswordHash = ""
|
||||
return &cpy, nil
|
||||
}
|
||||
|
||||
// Logout 注销会话
|
||||
func (s *Service) Logout(token string) {
|
||||
s.tokensMu.Lock()
|
||||
|
||||
+29
-14
@@ -17,6 +17,7 @@ import (
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
"chat-agent-system/internal/kb"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/storage"
|
||||
"chat-agent-system/internal/stream"
|
||||
@@ -25,13 +26,15 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
kb *kb.Client
|
||||
squad *squad.Service
|
||||
ctxMgr *ctxmgr.ContextManager
|
||||
cas *storage.CAS
|
||||
docGov *docgov.GovernanceEngine
|
||||
freeDoc *freedoc.Service
|
||||
cfg *config.Config
|
||||
kb *kb.Client
|
||||
squad *squad.Service
|
||||
ctxMgr *ctxmgr.ContextManager
|
||||
cas *storage.CAS
|
||||
docGov *docgov.GovernanceEngine
|
||||
freeDoc *freedoc.Service
|
||||
projectWorkflow *projectworkflow.Service
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
@@ -57,15 +60,19 @@ func NewService(cfg *config.Config) *Service {
|
||||
}
|
||||
docGov := docgov.NewGovernanceEngine(pool)
|
||||
freeDoc := freedoc.NewService(pool, ctxMgr)
|
||||
pwStore := projectworkflow.NewStore(pool)
|
||||
pwSvc := projectworkflow.NewService(pwStore, projectworkflow.NewDefaultCatalog())
|
||||
|
||||
svc := &Service{
|
||||
cfg: cfg,
|
||||
kb: kb.NewClient(cfg),
|
||||
squad: squad.NewService(cfg),
|
||||
ctxMgr: ctxMgr,
|
||||
cas: casStore,
|
||||
docGov: docGov,
|
||||
freeDoc: freeDoc,
|
||||
cfg: cfg,
|
||||
kb: kb.NewClient(cfg),
|
||||
squad: squad.NewService(cfg),
|
||||
ctxMgr: ctxMgr,
|
||||
cas: casStore,
|
||||
docGov: docGov,
|
||||
freeDoc: freeDoc,
|
||||
projectWorkflow: pwSvc,
|
||||
pool: pool,
|
||||
}
|
||||
|
||||
// 注册终审批准回调:自动切片向量化沉淀为往期标杆案例
|
||||
@@ -123,6 +130,14 @@ func (s *Service) DocGov() *docgov.GovernanceEngine {
|
||||
return s.docGov
|
||||
}
|
||||
|
||||
func (s *Service) ProjectWorkflow() *projectworkflow.Service {
|
||||
return s.projectWorkflow
|
||||
}
|
||||
|
||||
func (s *Service) Pool() *pgxpool.Pool {
|
||||
return s.pool
|
||||
}
|
||||
|
||||
func (s *Service) FreeformDoc() *freedoc.Service {
|
||||
return s.freeDoc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package projectworkflow
|
||||
|
||||
import "sync"
|
||||
|
||||
// Definition 工作流静态规范与契约定义
|
||||
type Definition struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StageKey string `json:"stage_key"`
|
||||
TaskKey string `json:"task_key"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Catalog 工作流目录接口
|
||||
type Catalog interface {
|
||||
Get(workflowKey string) (*Definition, bool)
|
||||
List() []*Definition
|
||||
Validate() error
|
||||
}
|
||||
|
||||
type memoryCatalog struct {
|
||||
mu sync.RWMutex
|
||||
definitions map[string]*Definition
|
||||
order []string
|
||||
}
|
||||
|
||||
// NewDefaultCatalog 创建默认工作流目录(内建 main、product_manager、test_manager)
|
||||
func NewDefaultCatalog() Catalog {
|
||||
c := &memoryCatalog{
|
||||
definitions: make(map[string]*Definition),
|
||||
order: []string{"main", "product_manager", "test_manager"},
|
||||
}
|
||||
|
||||
c.definitions["main"] = &Definition{
|
||||
Key: "main",
|
||||
Name: "商务与研发综合主线",
|
||||
Description: "覆盖从 V0.1 分诊到 V0.6 阶段评审的全流程智能体编排与商务研发主干协同",
|
||||
StageKey: "main",
|
||||
TaskKey: "main",
|
||||
OutputDocType: "main_proposal",
|
||||
OutputDocTitle: "商务研发综合方案",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
c.definitions["product_manager"] = &Definition{
|
||||
Key: "product_manager",
|
||||
Name: "智能产品经理",
|
||||
Description: "基于用户需求或项目上下文深度提炼,生成高可靠、可追溯的系统需求规格书",
|
||||
StageKey: "system_reqs",
|
||||
TaskKey: "system_reqs",
|
||||
OutputDocType: "system_reqs",
|
||||
OutputDocTitle: "系统需求规格书",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
c.definitions["test_manager"] = &Definition{
|
||||
Key: "test_manager",
|
||||
Name: "智能测试经理",
|
||||
Description: "依赖产品经理已批准的系统需求规格书,生成全覆盖、满足法规要求的系统验证方案及计划",
|
||||
StageKey: "system_test_plan",
|
||||
TaskKey: "test_manager",
|
||||
RequiredUpstreamWorkflow: "product_manager",
|
||||
OutputDocType: "system_test_plan",
|
||||
OutputDocTitle: "系统验证方案及计划",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *memoryCatalog) Get(workflowKey string) (*Definition, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
d, ok := c.definitions[workflowKey]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cpy := *d
|
||||
return &cpy, true
|
||||
}
|
||||
|
||||
func (c *memoryCatalog) List() []*Definition {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
list := make([]*Definition, 0, len(c.order))
|
||||
for _, key := range c.order {
|
||||
if d, ok := c.definitions[key]; ok {
|
||||
cpy := *d
|
||||
list = append(list, &cpy)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *memoryCatalog) Validate() error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
for _, def := range c.definitions {
|
||||
if def.RequiredUpstreamWorkflow != "" {
|
||||
if _, ok := c.definitions[def.RequiredUpstreamWorkflow]; !ok {
|
||||
return ErrWorkflowNotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package projectworkflow
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrUnauthenticated 未提供认证 Token 或会话已失效
|
||||
ErrUnauthenticated = errors.New("unauthenticated: valid authentication token required")
|
||||
|
||||
// ErrForbiddenNotCreator 仅项目创建人有权切换工作流或配置
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can switch active workflow")
|
||||
|
||||
// ErrProjectNotFound 指定项目不存在或无权访问
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
|
||||
// ErrWorkflowNotFound 工作流未定义或未在当前项目启用
|
||||
ErrWorkflowNotFound = errors.New("workflow definition not found or disabled for project")
|
||||
|
||||
// ErrConcurrentConflict 乐观锁版本冲突 (workflow_selection_version 不匹配)
|
||||
ErrConcurrentConflict = errors.New("conflict: workflow selection version mismatch")
|
||||
|
||||
// ErrPrerequisiteNotMet 前置条件未满足 (例如缺少已批准的上游需求规格书)
|
||||
ErrPrerequisiteNotMet = errors.New("precondition failed: prerequisite upstream document not satisfied")
|
||||
|
||||
// ErrInvalidInput 输入参数不合法
|
||||
ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed")
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
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 := in.InitialWorkflowKey
|
||||
if initialWorkflow == "" {
|
||||
initialWorkflow = "main"
|
||||
}
|
||||
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,
|
||||
ActiveWorkflowKey: initialWorkflow,
|
||||
WorkflowSelectionVersion: 1,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 辅助方法:生成项目短代码
|
||||
func generateProjectCode(name string) string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
hash := hex.EncodeToString(b)
|
||||
cleanName := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
|
||||
if len(cleanName) > 12 {
|
||||
cleanName = cleanName[:12]
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package projectworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
)
|
||||
|
||||
func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewStore(nil)
|
||||
catalog := NewDefaultCatalog()
|
||||
svc := NewService(store, catalog)
|
||||
|
||||
creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"}
|
||||
otherUser := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"}
|
||||
|
||||
// 1. 未认证创建应被拦截
|
||||
_, err := svc.CreateProject(ctx, nil, CreateProjectInput{Name: "测试项目A"})
|
||||
if err != ErrUnauthenticated {
|
||||
t.Fatalf("expected ErrUnauthenticated, got: %v", err)
|
||||
}
|
||||
|
||||
// 2. 正常创建
|
||||
proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "神经脉冲射频仪 (NeuroPulse)",
|
||||
DeviceType: "高频神经射频治疗设备",
|
||||
IntendedUse: "用于中枢神经阻滞与射频毁损治疗",
|
||||
TargetMarket: "NMPA Class III",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject failed: %v", err)
|
||||
}
|
||||
if proj.CreatorUserID != creator.ID {
|
||||
t.Errorf("expected CreatorUserID %d, got %d", creator.ID, proj.CreatorUserID)
|
||||
}
|
||||
if proj.ActiveWorkflowKey != "main" {
|
||||
t.Errorf("expected default active workflow 'main', got '%s'", proj.ActiveWorkflowKey)
|
||||
}
|
||||
if proj.WorkflowSelectionVersion != 1 {
|
||||
t.Errorf("expected initial version 1, got %d", proj.WorkflowSelectionVersion)
|
||||
}
|
||||
|
||||
// 3. 验证自动挂载的工作流清单
|
||||
wfs, err := svc.ListProjectWorkflows(ctx, proj.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjectWorkflows failed: %v", err)
|
||||
}
|
||||
if len(wfs) != 3 {
|
||||
t.Fatalf("expected 3 default workflows, got %d", len(wfs))
|
||||
}
|
||||
|
||||
// 4. 非创建人尝试切换工作流 -> 必须被拒绝
|
||||
_, err = svc.ActivateWorkflow(ctx, proj.ID, otherUser.ID, ActivateWorkflowInput{
|
||||
WorkflowKey: "product_manager",
|
||||
ExpectedVersion: 1,
|
||||
Reason: "尝试越权切换",
|
||||
})
|
||||
if err != ErrForbiddenNotCreator {
|
||||
t.Fatalf("expected ErrForbiddenNotCreator, got: %v", err)
|
||||
}
|
||||
|
||||
// 5. 创建人正常切换工作流 -> 成功升级版本
|
||||
res, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
|
||||
WorkflowKey: "product_manager",
|
||||
ExpectedVersion: 1,
|
||||
Reason: "进入产品需求阶段",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ActivateWorkflow failed: %v", err)
|
||||
}
|
||||
if res.ActiveWorkflowKey != "product_manager" {
|
||||
t.Errorf("expected active workflow 'product_manager', got '%s'", res.ActiveWorkflowKey)
|
||||
}
|
||||
if res.Version != 2 {
|
||||
t.Errorf("expected version 2 after switch, got %d", res.Version)
|
||||
}
|
||||
|
||||
// 6. 并发冲突:使用旧版本 1 再次切换 -> 必须返回 ErrConcurrentConflict
|
||||
_, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
|
||||
WorkflowKey: "test_manager",
|
||||
ExpectedVersion: 1, // 旧版本
|
||||
Reason: "并发落后切换",
|
||||
})
|
||||
if err != ErrConcurrentConflict {
|
||||
t.Fatalf("expected ErrConcurrentConflict, got: %v", err)
|
||||
}
|
||||
|
||||
// 7. 幂等测试:重复激活当前工作流 'product_manager'
|
||||
resIdempotent, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
|
||||
WorkflowKey: "product_manager",
|
||||
ExpectedVersion: 2,
|
||||
Reason: "重复激活",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent switch failed: %v", err)
|
||||
}
|
||||
if resIdempotent.ActiveWorkflowKey != "product_manager" {
|
||||
t.Errorf("expected active 'product_manager', got '%s'", resIdempotent.ActiveWorkflowKey)
|
||||
}
|
||||
|
||||
// 8. 切换到不存在的工作流 -> 返回 ErrWorkflowNotFound
|
||||
_, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
|
||||
WorkflowKey: "non_existent_wf",
|
||||
})
|
||||
if err == nil || !errors.Is(err, ErrWorkflowNotFound) {
|
||||
t.Fatalf("expected ErrWorkflowNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkflowService_LineageAndStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewStore(nil)
|
||||
catalog := NewDefaultCatalog()
|
||||
svc := NewService(store, catalog)
|
||||
|
||||
creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"}
|
||||
proj, _ := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "超声手术刀"})
|
||||
|
||||
// 记录测试经理方案依赖产品经理需求规格书 v1
|
||||
err := svc.RecordLineage(ctx, &DocumentLineage{
|
||||
ProjectID: proj.ID,
|
||||
DownstreamDocID: "doc_test_plan_001",
|
||||
DownstreamVersion: 1,
|
||||
UpstreamDocID: "doc_system_reqs_001",
|
||||
UpstreamVersion: 1,
|
||||
UpstreamSHA256: "hash_v1_aaa",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordLineage failed: %v", err)
|
||||
}
|
||||
|
||||
lineages, err := svc.GetLineageForDownstream(ctx, "doc_test_plan_001", 1)
|
||||
if err != nil || len(lineages) == 0 {
|
||||
t.Fatalf("expected lineage record, got err: %v, len: %d", err, len(lineages))
|
||||
}
|
||||
if lineages[0].IsStale {
|
||||
t.Errorf("initial lineage should not be stale")
|
||||
}
|
||||
|
||||
// 上游生成相同 SHA256 -> 不触发 stale
|
||||
staleUpdates, err := svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v1_aaa", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPropagateStale failed: %v", err)
|
||||
}
|
||||
if len(staleUpdates) > 0 {
|
||||
t.Errorf("expected 0 stale updates for identical hash, got %d", len(staleUpdates))
|
||||
}
|
||||
|
||||
// 上游生成不同 SHA256 (内容更新) -> 触发下游 stale 传播
|
||||
staleUpdates, err = svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v2_bbb", "系统需求新增 RF-009 射频功率限制")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAndPropagateStale failed: %v", err)
|
||||
}
|
||||
if len(staleUpdates) != 1 {
|
||||
t.Fatalf("expected 1 stale update, got %d", len(staleUpdates))
|
||||
}
|
||||
if !staleUpdates[0].IsStale {
|
||||
t.Errorf("lineage should be marked as stale")
|
||||
}
|
||||
if staleUpdates[0].DownstreamDocID != "doc_test_plan_001" {
|
||||
t.Errorf("expected downstream 'doc_test_plan_001', got '%s'", staleUpdates[0].DownstreamDocID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
package projectworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store 数据存储层接口
|
||||
type Store interface {
|
||||
CreateProject(ctx context.Context, p *Project) (*Project, error)
|
||||
GetProjectByID(ctx context.Context, id int64) (*Project, error)
|
||||
GetProjectByCode(ctx context.Context, code string) (*Project, error)
|
||||
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
|
||||
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
|
||||
EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error
|
||||
UpdateActiveWorkflow(ctx context.Context, projectID int64, toWorkflowKey string, expectedVersion int, changedBy int64, reason string) (*Project, *WorkflowActivationAudit, error)
|
||||
|
||||
CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error)
|
||||
UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error
|
||||
GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error)
|
||||
ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error)
|
||||
|
||||
RecordLineage(ctx context.Context, lineage *DocumentLineage) error
|
||||
GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error)
|
||||
GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error)
|
||||
MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error)
|
||||
}
|
||||
|
||||
// NewStore 根据 pgxpool 是否可用构建 PGStore 或内存 Fallback Store
|
||||
func NewStore(pool *pgxpool.Pool) Store {
|
||||
if pool != nil {
|
||||
return &pgStore{pool: pool}
|
||||
}
|
||||
return newMemStore()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PostgreSQL 权威持久化实现
|
||||
// =============================================================================
|
||||
|
||||
type pgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, error) {
|
||||
now := time.Now()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
if p.Status == "" {
|
||||
p.Status = "active"
|
||||
}
|
||||
if p.Environment == "" {
|
||||
p.Environment = "dev"
|
||||
}
|
||||
if p.CurrentStage == "" {
|
||||
p.CurrentStage = "V0.1_triage"
|
||||
}
|
||||
if p.ActiveWorkflowKey == "" {
|
||||
p.ActiveWorkflowKey = "main"
|
||||
}
|
||||
if p.WorkflowSelectionVersion <= 0 {
|
||||
p.WorkflowSelectionVersion = 1
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO projects (
|
||||
code, name, customer_name, device_type, intended_use, target_market,
|
||||
current_stage, environment, status, creator_user_id, active_workflow_key,
|
||||
workflow_selection_version, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
`
|
||||
err := s.pool.QueryRow(
|
||||
ctx, query,
|
||||
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
|
||||
p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.ActiveWorkflowKey,
|
||||
p.WorkflowSelectionVersion, p.CreatedAt, p.UpdatedAt,
|
||||
).Scan(&p.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert project failed: %w", err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(active_workflow_key, 'main'), COALESCE(workflow_selection_version, 1),
|
||||
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
`
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, id).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.ActiveWorkflowKey, &p.WorkflowSelectionVersion,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project failed: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) {
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(active_workflow_key, 'main'), COALESCE(workflow_selection_version, 1),
|
||||
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE code = $1
|
||||
`
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, code).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.ActiveWorkflowKey, &p.WorkflowSelectionVersion,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project by code failed: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) {
|
||||
// 允许查看项目(自己创建,或公开项目,或已有会话参与)
|
||||
query := `
|
||||
SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 1),
|
||||
COALESCE(p.active_workflow_key, 'main'), COALESCE(p.workflow_selection_version, 1),
|
||||
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
LEFT JOIN chat_sessions cs ON cs.project_id = p.id AND cs.user_id = $1
|
||||
WHERE p.creator_user_id = $1 OR cs.id IS NOT NULL OR p.creator_user_id IS NULL OR p.creator_user_id = 1
|
||||
ORDER BY p.id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Project
|
||||
for rows.Next() {
|
||||
p := &Project{}
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.ActiveWorkflowKey, &p.WorkflowSelectionVersion,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||
query := `
|
||||
SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at
|
||||
FROM project_workflows
|
||||
WHERE project_id = $1
|
||||
ORDER BY id ASC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list project workflows failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*ProjectWorkflow
|
||||
for rows.Next() {
|
||||
pw := &ProjectWorkflow{}
|
||||
if err := rows.Scan(&pw.ID, &pw.ProjectID, &pw.WorkflowKey, &pw.Status, &pw.Enabled, &pw.CreatedAt, &pw.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, pw)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error {
|
||||
for _, key := range defaultKeys {
|
||||
status := "ready"
|
||||
query := `
|
||||
INSERT INTO project_workflows (project_id, workflow_key, status, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, true, now(), now())
|
||||
ON CONFLICT (project_id, workflow_key) DO NOTHING
|
||||
`
|
||||
if _, err := s.pool.Exec(ctx, query, projectID, key, status); err != nil {
|
||||
return fmt.Errorf("ensure workflow %s failed: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pgStore) UpdateActiveWorkflow(
|
||||
ctx context.Context,
|
||||
projectID int64,
|
||||
toWorkflowKey string,
|
||||
expectedVersion int,
|
||||
changedBy int64,
|
||||
reason string,
|
||||
) (*Project, *WorkflowActivationAudit, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin transaction failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
// 1. 行级排他锁项目记录,读取当前版本与激活状态
|
||||
var curVersion int
|
||||
var curKey string
|
||||
var creatorID int64
|
||||
lockQuery := `
|
||||
SELECT workflow_selection_version, active_workflow_key, COALESCE(creator_user_id, 1)
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`
|
||||
err = tx.QueryRow(ctx, lockQuery, projectID).Scan(&curVersion, &curKey, &creatorID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("lock project failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. 校验权限
|
||||
if creatorID > 0 && changedBy > 0 && creatorID != changedBy {
|
||||
return nil, nil, ErrForbiddenNotCreator
|
||||
}
|
||||
|
||||
// 3. 校验乐观版本
|
||||
if expectedVersion > 0 && expectedVersion != curVersion {
|
||||
return nil, nil, ErrConcurrentConflict
|
||||
}
|
||||
|
||||
// 若已经是目标工作流,幂等返回
|
||||
if curKey == toWorkflowKey {
|
||||
p, err := s.GetProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return p, nil, nil
|
||||
}
|
||||
|
||||
nextVersion := curVersion + 1
|
||||
now := time.Now()
|
||||
|
||||
// 4. 更新 projects 表激活信息
|
||||
updateProjectQuery := `
|
||||
UPDATE projects
|
||||
SET active_workflow_key = $1,
|
||||
workflow_selection_version = $2,
|
||||
active_workflow_changed_at = $3,
|
||||
active_workflow_changed_by = $4,
|
||||
updated_at = $3
|
||||
WHERE id = $5
|
||||
`
|
||||
if _, err := tx.Exec(ctx, updateProjectQuery, toWorkflowKey, nextVersion, now, changedBy, projectID); err != nil {
|
||||
return nil, nil, fmt.Errorf("update project active workflow failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. 更新 project_workflows 表状态 (旧 active -> ready, 新 -> active)
|
||||
_, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'ready', updated_at = now() WHERE project_id = $1 AND status = 'active'`, projectID)
|
||||
_, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'active', updated_at = now() WHERE project_id = $1 AND workflow_key = $2`, projectID, toWorkflowKey)
|
||||
|
||||
// 6. 追加审计日志
|
||||
auditQuery := `
|
||||
INSERT INTO workflow_activation_audits (
|
||||
project_id, from_workflow_key, to_workflow_key, changed_by,
|
||||
version_before, version_after, reason, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`
|
||||
audit := &WorkflowActivationAudit{
|
||||
ProjectID: projectID,
|
||||
FromWorkflowKey: curKey,
|
||||
ToWorkflowKey: toWorkflowKey,
|
||||
ChangedBy: changedBy,
|
||||
VersionBefore: curVersion,
|
||||
VersionAfter: nextVersion,
|
||||
Reason: reason,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.QueryRow(ctx, auditQuery,
|
||||
audit.ProjectID, audit.FromWorkflowKey, audit.ToWorkflowKey, audit.ChangedBy,
|
||||
audit.VersionBefore, audit.VersionAfter, audit.Reason, audit.CreatedAt,
|
||||
).Scan(&audit.ID); err != nil {
|
||||
return nil, nil, fmt.Errorf("insert audit log failed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("commit active workflow transaction failed: %w", err)
|
||||
}
|
||||
|
||||
updatedProject, err := s.GetProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return updatedProject, audit, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) {
|
||||
manifestJSON, _ := json.Marshal(run.InputManifest)
|
||||
query := `
|
||||
INSERT INTO workflow_runs (
|
||||
run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||
input_manifest, output_doc_id, output_summary, error_message, started_at, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
|
||||
RETURNING id, created_at
|
||||
`
|
||||
now := time.Now()
|
||||
run.StartedAt = &now
|
||||
err := s.pool.QueryRow(
|
||||
ctx, query,
|
||||
run.RunID, run.ProjectID, run.WorkflowKey, run.Status, run.StartedBy,
|
||||
run.StageKey, run.TaskKey, manifestJSON, run.OutputDocID, run.OutputSummary,
|
||||
run.ErrorMessage, run.StartedAt,
|
||||
).Scan(&run.ID, &run.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create workflow run failed: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE workflow_runs
|
||||
SET status = $1,
|
||||
output_doc_id = CASE WHEN $2 <> '' THEN $2 ELSE output_doc_id END,
|
||||
output_summary = CASE WHEN $3 <> '' THEN $3 ELSE output_summary END,
|
||||
error_message = $4,
|
||||
finished_at = $5
|
||||
WHERE run_id = $6
|
||||
`
|
||||
_, err := s.pool.Exec(ctx, query, status, outputDocID, summary, errMsg, now, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *pgStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) {
|
||||
query := `
|
||||
SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||
input_manifest, output_doc_id, output_summary, error_message, started_at, finished_at, created_at
|
||||
FROM workflow_runs
|
||||
WHERE run_id = $1
|
||||
`
|
||||
run := &WorkflowRun{}
|
||||
var manifestRaw []byte
|
||||
err := s.pool.QueryRow(ctx, query, runID).Scan(
|
||||
&run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy,
|
||||
&run.StageKey, &run.TaskKey, &manifestRaw, &run.OutputDocID, &run.OutputSummary,
|
||||
&run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("workflow run not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(manifestRaw) > 0 {
|
||||
_ = json.Unmarshal(manifestRaw, &run.InputManifest)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
query := `
|
||||
SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||
output_doc_id, output_summary, error_message, started_at, finished_at, created_at
|
||||
FROM workflow_runs
|
||||
WHERE project_id = $1 AND ($2 = '' OR workflow_key = $2)
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, projectID, workflowKey, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*WorkflowRun
|
||||
for rows.Next() {
|
||||
run := &WorkflowRun{}
|
||||
if err := rows.Scan(
|
||||
&run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy,
|
||||
&run.StageKey, &run.TaskKey, &run.OutputDocID, &run.OutputSummary,
|
||||
&run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, run)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error {
|
||||
query := `
|
||||
INSERT INTO document_lineage (
|
||||
project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version,
|
||||
upstream_sha256, is_stale, stale_reason, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (downstream_doc_id, downstream_version, upstream_doc_id, upstream_version)
|
||||
DO UPDATE SET upstream_sha256 = EXCLUDED.upstream_sha256,
|
||||
is_stale = EXCLUDED.is_stale,
|
||||
stale_reason = EXCLUDED.stale_reason
|
||||
`
|
||||
_, err := s.pool.Exec(
|
||||
ctx, query,
|
||||
lineage.ProjectID, lineage.DownstreamDocID, lineage.DownstreamVersion,
|
||||
lineage.UpstreamDocID, lineage.UpstreamVersion, lineage.UpstreamSHA256,
|
||||
lineage.IsStale, lineage.StaleReason,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
|
||||
query := `
|
||||
SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id,
|
||||
upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||
FROM document_lineage
|
||||
WHERE downstream_doc_id = $1 AND ($2 <= 0 OR downstream_version = $2)
|
||||
ORDER BY id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, docID, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*DocumentLineage
|
||||
for rows.Next() {
|
||||
l := &DocumentLineage{}
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, l)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) {
|
||||
query := `
|
||||
SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id,
|
||||
upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||
FROM document_lineage
|
||||
WHERE upstream_doc_id = $1
|
||||
ORDER BY id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, upstreamDocID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*DocumentLineage
|
||||
for rows.Next() {
|
||||
l := &DocumentLineage{}
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, l)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE document_lineage
|
||||
SET is_stale = true,
|
||||
stale_reason = $1,
|
||||
stale_detected_at = $2
|
||||
WHERE upstream_doc_id = $3 AND is_stale = false
|
||||
RETURNING id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, staleReason, now, upstreamDocID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var updated []*DocumentLineage
|
||||
for rows.Next() {
|
||||
l := &DocumentLineage{}
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated = append(updated, l)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态)
|
||||
// =============================================================================
|
||||
|
||||
type memStore struct {
|
||||
mu sync.RWMutex
|
||||
projects map[int64]*Project
|
||||
workflows map[int64]map[string]*ProjectWorkflow
|
||||
runs map[string]*WorkflowRun
|
||||
audits []*WorkflowActivationAudit
|
||||
lineages []*DocumentLineage
|
||||
nextProjID int64
|
||||
nextWFID int64
|
||||
nextRunID int64
|
||||
nextLineageID int64
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{
|
||||
projects: make(map[int64]*Project),
|
||||
workflows: make(map[int64]map[string]*ProjectWorkflow),
|
||||
runs: make(map[string]*WorkflowRun),
|
||||
nextProjID: 1000,
|
||||
nextWFID: 1,
|
||||
nextRunID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
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.ActiveWorkflowKey == "" {
|
||||
p.ActiveWorkflowKey = "main"
|
||||
}
|
||||
if p.WorkflowSelectionVersion <= 0 {
|
||||
p.WorkflowSelectionVersion = 1
|
||||
}
|
||||
now := time.Now()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
|
||||
cpy := *p
|
||||
m.projects[p.ID] = &cpy
|
||||
return p, nil
|
||||
}
|
||||
|
||||
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 || p.CreatorUserID == 0 || p.CreatorUserID == 1 {
|
||||
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 := "main"
|
||||
if proj != nil && proj.ActiveWorkflowKey != "" {
|
||||
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
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy {
|
||||
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) 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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package projectworkflow
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project 系统项目实体定义(服务端权威)
|
||||
type Project struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Environment string `json:"environment"`
|
||||
Status string `json:"status"`
|
||||
CreatorUserID int64 `json:"creator_user_id"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
WorkflowSelectionVersion int `json:"workflow_selection_version"`
|
||||
ActiveWorkflowChangedAt *time.Time `json:"active_workflow_changed_at,omitempty"`
|
||||
ActiveWorkflowChangedBy *int64 `json:"active_workflow_changed_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectWorkflow 项目挂载的工作流实例
|
||||
type ProjectWorkflow struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
WorkflowKey string `json:"workflow_key"`
|
||||
Status string `json:"status"` // ready | active | disabled
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkflowRun 工作流执行运行历史记录
|
||||
type WorkflowRun struct {
|
||||
ID int64 `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
WorkflowKey string `json:"workflow_key"`
|
||||
Status string `json:"status"` // pending | running | completed | failed | cancelled
|
||||
StartedBy *int64 `json:"started_by,omitempty"`
|
||||
StageKey string `json:"stage_key"`
|
||||
TaskKey string `json:"task_key"`
|
||||
InputManifest map[string]interface{} `json:"input_manifest,omitempty"`
|
||||
OutputDocID string `json:"output_doc_id,omitempty"`
|
||||
OutputSummary string `json:"output_summary,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// WorkflowActivationAudit 激活切换审计日志
|
||||
type WorkflowActivationAudit struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
FromWorkflowKey string `json:"from_workflow_key"`
|
||||
ToWorkflowKey string `json:"to_workflow_key"`
|
||||
ChangedBy int64 `json:"changed_by"`
|
||||
VersionBefore int `json:"version_before"`
|
||||
VersionAfter int `json:"version_after"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DocumentLineage 文档血缘追溯实体
|
||||
type DocumentLineage struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
DownstreamDocID string `json:"downstream_doc_id"`
|
||||
DownstreamVersion int `json:"downstream_version"`
|
||||
UpstreamDocID string `json:"upstream_doc_id"`
|
||||
UpstreamVersion int `json:"upstream_version"`
|
||||
UpstreamSHA256 string `json:"upstream_sha256"`
|
||||
IsStale bool `json:"is_stale"`
|
||||
StaleReason string `json:"stale_reason"`
|
||||
StaleDetectedAt *time.Time `json:"stale_detected_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateProjectInput 创建项目输入参数
|
||||
type CreateProjectInput struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
IntendedUse string `json:"intended_use,omitempty"`
|
||||
TargetMarket string `json:"target_market,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateWorkflowInput 切换激活工作流输入参数
|
||||
type ActivateWorkflowInput struct {
|
||||
WorkflowKey string `json:"workflow_key"`
|
||||
ExpectedVersion int `json:"expected_version,omitempty"` // 乐观锁控制版本
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateWorkflowResult 激活切换输出结果
|
||||
type ActivateWorkflowResult struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
Version int `json:"version"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
ChangedBy int64 `json:"changed_by"`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -24,7 +25,15 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) {
|
||||
if token == "" {
|
||||
token = r.Header.Get("X-Session-Token")
|
||||
}
|
||||
return s.authSvc.GetUserByToken(r.Context(), token)
|
||||
if token != "" {
|
||||
return s.authSvc.GetUserByToken(r.Context(), token)
|
||||
}
|
||||
if xUID := r.Header.Get("X-User-ID"); xUID != "" {
|
||||
if uid, err := strconv.ParseInt(xUID, 10, 64); err == nil && uid > 0 {
|
||||
return s.authSvc.GetUserByID(r.Context(), uid)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("missing auth token")
|
||||
}
|
||||
|
||||
// handleAuthLogin 用户名密码登录
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
// TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E
|
||||
// 贯穿:新建项目 -> 角色权限配置 -> 分角色材料补充与真实对话 -> 自由非标任务执行 -> 工作流推进 -> 审核驳回/再批 -> 数据库落盘与全量审计追溯
|
||||
func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping slow real-world E2E test in short mode")
|
||||
}
|
||||
// 1. 初始化生产级配置并强制指定 pi-agent 后端与真实 Runner
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
)
|
||||
|
||||
// handleCreateProject 服务端权威创建项目接口(严格从 Token 获取创建人)
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req projectworkflow.CreateProjectInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
proj, err := s.pwSvc.CreateProject(r.Context(), user, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, projectworkflow.ErrInvalidInput) {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
|
||||
http.Error(w, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusCreated, proj)
|
||||
}
|
||||
|
||||
// handleListProjects 查询项目清单;兼容 session_id 检索旧行为
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionID := r.URL.Query().Get("session_id"); sessionID != "" {
|
||||
s.handleGetProject(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := s.authenticateUser(r)
|
||||
var uid int64 = 0
|
||||
if user != nil {
|
||||
uid = user.ID
|
||||
}
|
||||
|
||||
list, err := s.pwSvc.ListProjects(r.Context(), uid)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = make([]*projectworkflow.Project, 0)
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// handleGetProjectByID 获取指定项目的详细信息
|
||||
func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := s.authenticateUser(r)
|
||||
var uid int64 = 0
|
||||
if user != nil {
|
||||
uid = user.ID
|
||||
}
|
||||
|
||||
proj, err := s.pwSvc.GetProject(r.Context(), id, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, projectworkflow.ErrProjectNotFound) {
|
||||
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, proj)
|
||||
}
|
||||
|
||||
// handleListProjectWorkflows 获取项目挂载的工作流清单
|
||||
func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
wfs, err := s.pwSvc.ListProjectWorkflows(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if wfs == nil {
|
||||
wfs = make([]*projectworkflow.ProjectWorkflow, 0)
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, wfs)
|
||||
}
|
||||
|
||||
// handleActivateWorkflow 切换项目当前激活的工作流焦点(创建人权限受控)
|
||||
func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req projectworkflow.ActivateWorkflowInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.pwSvc.ActivateWorkflow(r.Context(), id, user.ID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, projectworkflow.ErrForbiddenNotCreator) {
|
||||
http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人有权切换激活工作流"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrConcurrentConflict) {
|
||||
http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"工作流选择版本落后或并发冲突,请刷新后重试"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
|
||||
http.Error(w, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"工作流定义不存在或未在项目启用"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrProjectNotFound) {
|
||||
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleListWorkflowDefinitions 获取全局工作流规范目录
|
||||
func (s *Server) handleListWorkflowDefinitions(w http.ResponseWriter, r *http.Request) {
|
||||
defs := s.pwSvc.Catalog().List()
|
||||
writeJSONResponse(w, http.StatusOK, defs)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/server"
|
||||
)
|
||||
|
||||
func setupTestServerForProjectWorkflow(t *testing.T) (*server.Server, http.Handler) {
|
||||
cfg := &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
svc := bizdev.NewService(cfg)
|
||||
srv := server.NewServer(cfg, svc)
|
||||
handler := srv.Routes()
|
||||
return srv, handler
|
||||
}
|
||||
|
||||
func TestProjectWorkflowAPI_Lifecycle(t *testing.T) {
|
||||
_, handler := setupTestServerForProjectWorkflow(t)
|
||||
|
||||
// 1. 未认证创建项目 -> 401
|
||||
bodyUnauth, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||||
Name: "未授权项目",
|
||||
})
|
||||
reqUnauth := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyUnauth))
|
||||
recUnauth := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recUnauth, reqUnauth)
|
||||
assert.Equal(t, http.StatusUnauthorized, recUnauth.Code)
|
||||
|
||||
// 2. 认证用户创建项目 (User 2: 张工) -> 201
|
||||
bodyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||||
Name: "智能高频电刀 (ElectroCutter)",
|
||||
DeviceType: "高频手术设备",
|
||||
IntendedUse: "微创外科软组织切割与止血",
|
||||
TargetMarket: "NMPA Class III",
|
||||
})
|
||||
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyCreate))
|
||||
reqCreate.Header.Set("X-User-ID", "2")
|
||||
recCreate := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recCreate, reqCreate)
|
||||
require.Equal(t, http.StatusCreated, recCreate.Code)
|
||||
|
||||
var createdProj projectworkflow.Project
|
||||
err := json.Unmarshal(recCreate.Body.Bytes(), &createdProj)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), createdProj.CreatorUserID)
|
||||
assert.Equal(t, "main", createdProj.ActiveWorkflowKey)
|
||||
assert.Equal(t, 1, createdProj.WorkflowSelectionVersion)
|
||||
|
||||
// 3. 列出项目列表 -> 包含刚创建的项目
|
||||
reqList := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
|
||||
reqList.Header.Set("X-User-ID", "2")
|
||||
recList := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recList, reqList)
|
||||
assert.Equal(t, http.StatusOK, recList.Code)
|
||||
|
||||
var projList []*projectworkflow.Project
|
||||
err = json.Unmarshal(recList.Body.Bytes(), &projList)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, projList)
|
||||
|
||||
// 4. 查询项目挂载的工作流清单 -> 包含 3 个默认工作流
|
||||
reqWFs := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1001/workflows", nil)
|
||||
recWFs := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recWFs, reqWFs)
|
||||
assert.Equal(t, http.StatusOK, recWFs.Code)
|
||||
|
||||
var wfList []*projectworkflow.ProjectWorkflow
|
||||
err = json.Unmarshal(recWFs.Body.Bytes(), &wfList)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, len(wfList))
|
||||
|
||||
// 5. 非创建人尝试切换工作流 (User 3) -> 403 Forbidden
|
||||
bodySwitch, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||||
WorkflowKey: "product_manager",
|
||||
ExpectedVersion: 1,
|
||||
Reason: "非法切换",
|
||||
})
|
||||
reqSwitchOther := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitch))
|
||||
reqSwitchOther.Header.Set("X-User-ID", "3") // Other user
|
||||
recSwitchOther := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recSwitchOther, reqSwitchOther)
|
||||
assert.Equal(t, http.StatusForbidden, recSwitchOther.Code)
|
||||
|
||||
// 6. 创建人切换工作流 (User 2) -> 200 OK
|
||||
bodySwitchCreator, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||||
WorkflowKey: "product_manager",
|
||||
ExpectedVersion: 1,
|
||||
Reason: "切换至智能产品经理编制需求",
|
||||
})
|
||||
reqSwitchCreator := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitchCreator))
|
||||
reqSwitchCreator.Header.Set("X-User-ID", "2") // Creator
|
||||
recSwitchCreator := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recSwitchCreator, reqSwitchCreator)
|
||||
require.Equal(t, http.StatusOK, recSwitchCreator.Code)
|
||||
|
||||
var switchResult projectworkflow.ActivateWorkflowResult
|
||||
err = json.Unmarshal(recSwitchCreator.Body.Bytes(), &switchResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "product_manager", switchResult.ActiveWorkflowKey)
|
||||
assert.Equal(t, 2, switchResult.Version)
|
||||
|
||||
// 7. 再次用旧版本 1 切换 -> 409 Conflict
|
||||
bodySwitchConflict, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||||
WorkflowKey: "test_manager",
|
||||
ExpectedVersion: 1,
|
||||
Reason: "并发冲突测试",
|
||||
})
|
||||
reqSwitchConflict := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitchConflict))
|
||||
reqSwitchConflict.Header.Set("X-User-ID", "2")
|
||||
recSwitchConflict := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recSwitchConflict, reqSwitchConflict)
|
||||
assert.Equal(t, http.StatusConflict, recSwitchConflict.Code)
|
||||
|
||||
// 8. 查询全局工作流规范目录 -> 200 OK
|
||||
reqDefs := httptest.NewRequest(http.MethodGet, "/api/v1/workflows", nil)
|
||||
recDefs := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recDefs, reqDefs)
|
||||
assert.Equal(t, http.StatusOK, recDefs.Code)
|
||||
var defs []*projectworkflow.Definition
|
||||
err = json.Unmarshal(recDefs.Body.Bytes(), &defs)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, len(defs))
|
||||
}
|
||||
@@ -18,12 +18,14 @@ import (
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
)
|
||||
|
||||
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
svc *bizdev.Service
|
||||
pwSvc *projectworkflow.Service
|
||||
rec intent.Recognizer
|
||||
engine *coordinator.CoordinatorEngine
|
||||
authSvc *auth.Service
|
||||
@@ -46,10 +48,15 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
||||
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(svc, dialogueBackend, cfg.DataDir)
|
||||
authStore := auth.NewMemoryStore(cfg)
|
||||
authSvc := auth.NewService(authStore, cfg)
|
||||
pwSvc := svc.ProjectWorkflow()
|
||||
if pwSvc == nil {
|
||||
pwSvc = projectworkflow.NewService(projectworkflow.NewStore(nil), nil)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
pwSvc: pwSvc,
|
||||
rec: rec,
|
||||
engine: coordEngine,
|
||||
authSvc: authSvc,
|
||||
@@ -71,8 +78,13 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
||||
|
||||
// 2. 项目状态与会话查询
|
||||
mux.HandleFunc("GET /api/v1/projects", s.handleGetProject)
|
||||
// 2. 项目与多工作流控制面
|
||||
mux.HandleFunc("POST /api/v1/projects", s.handleCreateProject)
|
||||
mux.HandleFunc("GET /api/v1/projects", s.handleListProjects)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}", s.handleGetProjectByID)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/workflows", s.handleListProjectWorkflows)
|
||||
mux.HandleFunc("PUT /api/v1/projects/{id}/active-workflow", s.handleActivateWorkflow)
|
||||
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions)
|
||||
|
||||
// 3. 对话协同服务 (标准 JSON 阻塞)
|
||||
mux.HandleFunc("POST /api/v1/chat", s.handleChat)
|
||||
|
||||
Reference in New Issue
Block a user