New projectinstance package validates three-duty separation, issues confirmation tokens, and activates immutable workflow instances; new projects start in planning mode. Co-authored-by: Cursor <cursoragent@cursor.com>
896 lines
28 KiB
Go
896 lines
28 KiB
Go
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.LifecycleStatus == "" {
|
|
p.LifecycleStatus = "planning"
|
|
}
|
|
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, lifecycle_status,
|
|
active_workflow_key, workflow_selection_version, workflow_plan_revision,
|
|
created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
|
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.LifecycleStatus,
|
|
p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision,
|
|
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(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
|
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.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
|
&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(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
|
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.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
|
&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.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
|
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
|
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.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
|
&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. 校验权限 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换)
|
|
isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020)
|
|
if creatorID > 0 && changedBy > 0 && creatorID != changedBy && changedBy != 1 && !isReviewOrDemo {
|
|
return nil, nil, ErrForbiddenNotCreator
|
|
}
|
|
|
|
// 3. 校验乐观版本
|
|
if expectedVersion > 0 && expectedVersion != curVersion {
|
|
if isReviewOrDemo {
|
|
// 在评审与演示模式下自动采用当前版本自增,避免前端多端/调试导致的版本冲突阻断
|
|
expectedVersion = curVersion
|
|
} else {
|
|
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 {
|
|
m := &memStore{
|
|
projects: make(map[int64]*Project),
|
|
workflows: make(map[int64]map[string]*ProjectWorkflow),
|
|
runs: make(map[string]*WorkflowRun),
|
|
nextProjID: 1000,
|
|
nextWFID: 1,
|
|
nextRunID: 1,
|
|
nextLineageID: 1,
|
|
}
|
|
now := time.Now()
|
|
m.projects[1] = &Project{
|
|
ID: 1,
|
|
Code: "PROJ-001",
|
|
Name: "医用超声高频能量手术集成系统",
|
|
CustomerName: "北医三院创新转化中心",
|
|
DeviceType: "III类医疗器械",
|
|
IntendedUse: "用于外科手术中软组织的切割与血管止血",
|
|
TargetMarket: "中国 NMPA + 欧盟 CE MDR",
|
|
CurrentStage: "stage_1_spec",
|
|
Environment: "dev",
|
|
Status: "active",
|
|
CreatorUserID: 1,
|
|
ActiveWorkflowKey: "main",
|
|
WorkflowSelectionVersion: 1,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
m.projects[5201] = &Project{
|
|
ID: 5201,
|
|
Code: "CUSA-NEUROPULSE",
|
|
Name: "神经外科双频超声吸引系统 (CUSA-NeuroPulse)",
|
|
CustomerName: "天坛医院神经外科研究所",
|
|
DeviceType: "III类医疗器械",
|
|
IntendedUse: "用于神经外科颅内脑肿瘤精准超声粉碎吸引与止血",
|
|
TargetMarket: "中国 NMPA + 欧盟 CE MDR",
|
|
CurrentStage: "stage_1_spec",
|
|
Environment: "dev",
|
|
Status: "active",
|
|
CreatorUserID: 1,
|
|
ActiveWorkflowKey: "main",
|
|
WorkflowSelectionVersion: 1,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.nextProjID++
|
|
p.ID = m.nextProjID
|
|
if p.Status == "" {
|
|
p.Status = "active"
|
|
}
|
|
if p.LifecycleStatus == "" {
|
|
p.LifecycleStatus = "planning"
|
|
}
|
|
if p.WorkflowSelectionVersion <= 0 {
|
|
p.WorkflowSelectionVersion = 1
|
|
}
|
|
now := time.Now()
|
|
p.CreatedAt = now
|
|
p.UpdatedAt = now
|
|
|
|
cpy := *p
|
|
m.projects[p.ID] = &cpy
|
|
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
|
|
}
|
|
|
|
// 权限检查 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换)
|
|
isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020)
|
|
if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy && changedBy != 1 && !isReviewOrDemo {
|
|
return nil, nil, ErrForbiddenNotCreator
|
|
}
|
|
|
|
// 乐观锁检查
|
|
if expectedVersion > 0 && expectedVersion != p.WorkflowSelectionVersion {
|
|
return nil, nil, ErrConcurrentConflict
|
|
}
|
|
|
|
// 幂等
|
|
if p.ActiveWorkflowKey == toWorkflowKey {
|
|
cpy := *p
|
|
return &cpy, nil, nil
|
|
}
|
|
|
|
curVersion := p.WorkflowSelectionVersion
|
|
nextVersion := curVersion + 1
|
|
now := time.Now()
|
|
|
|
fromKey := p.ActiveWorkflowKey
|
|
p.ActiveWorkflowKey = toWorkflowKey
|
|
p.WorkflowSelectionVersion = nextVersion
|
|
p.ActiveWorkflowChangedAt = &now
|
|
p.ActiveWorkflowChangedBy = &changedBy
|
|
p.UpdatedAt = now
|
|
|
|
// 更新挂载的工作流状态
|
|
if wfs, ok := m.workflows[projectID]; ok {
|
|
for k, wf := range wfs {
|
|
if k == toWorkflowKey {
|
|
wf.Status = "active"
|
|
wf.UpdatedAt = now
|
|
} else if wf.Status == "active" {
|
|
wf.Status = "ready"
|
|
wf.UpdatedAt = now
|
|
}
|
|
}
|
|
}
|
|
|
|
audit := &WorkflowActivationAudit{
|
|
ID: int64(len(m.audits) + 1),
|
|
ProjectID: projectID,
|
|
FromWorkflowKey: fromKey,
|
|
ToWorkflowKey: toWorkflowKey,
|
|
ChangedBy: changedBy,
|
|
VersionBefore: curVersion,
|
|
VersionAfter: nextVersion,
|
|
Reason: reason,
|
|
CreatedAt: now,
|
|
}
|
|
m.audits = append(m.audits, audit)
|
|
|
|
cpy := *p
|
|
return &cpy, audit, nil
|
|
}
|
|
|
|
func (m *memStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.nextRunID++
|
|
run.ID = m.nextRunID
|
|
now := time.Now()
|
|
run.CreatedAt = now
|
|
run.StartedAt = &now
|
|
cpy := *run
|
|
m.runs[run.RunID] = &cpy
|
|
return run, nil
|
|
}
|
|
|
|
func (m *memStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
run, ok := m.runs[runID]
|
|
if !ok {
|
|
return errors.New("workflow run not found")
|
|
}
|
|
now := time.Now()
|
|
run.Status = status
|
|
if outputDocID != "" {
|
|
run.OutputDocID = outputDocID
|
|
}
|
|
if summary != "" {
|
|
run.OutputSummary = summary
|
|
}
|
|
run.ErrorMessage = errMsg
|
|
run.FinishedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
run, ok := m.runs[runID]
|
|
if !ok {
|
|
return nil, errors.New("workflow run not found")
|
|
}
|
|
cpy := *run
|
|
return &cpy, nil
|
|
}
|
|
|
|
func (m *memStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var list []*WorkflowRun
|
|
for _, r := range m.runs {
|
|
if r.ProjectID == projectID && (workflowKey == "" || r.WorkflowKey == workflowKey) {
|
|
cpy := *r
|
|
list = append(list, &cpy)
|
|
}
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func (m *memStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.nextLineageID++
|
|
lineage.ID = m.nextLineageID
|
|
lineage.CreatedAt = time.Now()
|
|
cpy := *lineage
|
|
m.lineages = append(m.lineages, &cpy)
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var list []*DocumentLineage
|
|
for _, l := range m.lineages {
|
|
if l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) {
|
|
cpy := *l
|
|
list = append(list, &cpy)
|
|
}
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func (m *memStore) 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
|
|
}
|