feat(db): add workflow definitions, versions, and template asset tables
Introduce Phase 2 IAM-adjacent schema for versioned workflow definitions, template assets/versions, and workflow_defs permissions seed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
a24b00d393
commit
edffe74009
@@ -0,0 +1,99 @@
|
||||
-- +goose Up
|
||||
-- Phase 2: 工作流定义版本化 + 模板资产库
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
latest_published_version INT NOT NULL DEFAULT 0,
|
||||
product_domain TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_id BIGINT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE,
|
||||
version INT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
definition_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
definition_hash TEXT NOT NULL DEFAULT '',
|
||||
published_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_workflow_version UNIQUE (workflow_id, version),
|
||||
CONSTRAINT chk_workflow_version_status CHECK (status IN ('draft', 'published', 'retired'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_one_draft
|
||||
ON workflow_versions(workflow_id)
|
||||
WHERE status = 'draft';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_status ON workflow_versions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow ON workflow_versions(workflow_id, version DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS template_assets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
asset_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS template_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
asset_id BIGINT NOT NULL REFERENCES template_assets(id) ON DELETE CASCADE,
|
||||
version INT NOT NULL,
|
||||
blob_sha256 TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
scan_status TEXT NOT NULL DEFAULT 'pending',
|
||||
variable_schema JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_template_asset_version UNIQUE (asset_id, version),
|
||||
CONSTRAINT chk_template_scan_status CHECK (scan_status IN ('pending', 'ready', 'rejected'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_template_versions_asset ON template_versions(asset_id, version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_template_versions_sha ON template_versions(blob_sha256);
|
||||
|
||||
INSERT INTO system_permissions (perm_key, resource_type, action, risk_level) VALUES
|
||||
('workflow_defs.view', 'workflow', 'view', 'normal'),
|
||||
('workflow_defs.create', 'workflow', 'create', 'elevated'),
|
||||
('workflow_defs.edit_draft', 'workflow', 'edit_draft', 'elevated'),
|
||||
('workflow_defs.publish', 'workflow', 'publish', 'critical'),
|
||||
('workflow_defs.retire', 'workflow', 'retire', 'elevated'),
|
||||
('templates.upload', 'template', 'upload', 'elevated'),
|
||||
('templates.download', 'template', 'download', 'normal'),
|
||||
('templates.publish', 'template', 'publish', 'elevated')
|
||||
ON CONFLICT (perm_key) DO NOTHING;
|
||||
|
||||
INSERT INTO system_role_permissions (role_id, permission_id, effect)
|
||||
SELECT 'role-admin', p.id, 'allow'
|
||||
FROM system_permissions p
|
||||
WHERE p.perm_key LIKE 'workflow_defs.%' OR p.perm_key LIKE 'templates.%'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS template_versions;
|
||||
DROP TABLE IF EXISTS template_assets;
|
||||
DROP INDEX IF EXISTS uq_workflow_one_draft;
|
||||
DROP INDEX IF EXISTS idx_workflow_versions_status;
|
||||
DROP INDEX IF EXISTS idx_workflow_versions_workflow;
|
||||
DROP TABLE IF EXISTS workflow_versions;
|
||||
DROP TABLE IF EXISTS workflow_definitions;
|
||||
|
||||
DELETE FROM system_role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM system_permissions
|
||||
WHERE perm_key LIKE 'workflow_defs.%' OR perm_key LIKE 'templates.%'
|
||||
);
|
||||
DELETE FROM system_permissions
|
||||
WHERE perm_key LIKE 'workflow_defs.%' OR perm_key LIKE 'templates.%';
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/storage"
|
||||
"chat-agent-system/internal/stream"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -88,7 +89,13 @@ func NewService(cfg *config.Config) (*Service, error) {
|
||||
docGov := docgov.NewGovernanceEngine(pool)
|
||||
freeDoc := freedoc.NewService(pool, ctxMgr)
|
||||
pwStore := projectworkflow.NewStore(pool)
|
||||
pwSvc := projectworkflow.NewService(pwStore, projectworkflow.NewDefaultCatalog())
|
||||
fallbackCatalog := projectworkflow.NewDefaultCatalog()
|
||||
wfStore := workflowdef.NewStore(pool)
|
||||
if pool != nil && workflowdef.TablesReady(context.Background(), pool) {
|
||||
_ = workflowdef.BootstrapSeeds(context.Background(), pool, casStore, "templates/docs", 1)
|
||||
}
|
||||
catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog)
|
||||
pwSvc := projectworkflow.NewService(pwStore, catalog)
|
||||
|
||||
svc := &Service{
|
||||
cfg: cfg,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/storage"
|
||||
)
|
||||
|
||||
func legacySingleStage(key, name, desc, stageKey, taskKey, docType, docTitle, upstream string) CreateDefinitionInput {
|
||||
return CreateDefinitionInput{
|
||||
Key: key,
|
||||
Name: name,
|
||||
Description: desc,
|
||||
ProductDomain: "medical_device",
|
||||
Definition: VersionDefinition{
|
||||
SchemaVersion: 1,
|
||||
RequiredUpstreamWorkflow: upstream,
|
||||
Stages: []StageDef{{
|
||||
Key: stageKey, Name: stageKey, Position: 0,
|
||||
DocTasks: []DocTaskDef{{
|
||||
Key: taskKey, Title: docTitle, Required: true, Blocking: true, DefaultEnabled: true,
|
||||
OutputDocType: docType, OutputDocTitle: docTitle,
|
||||
TemplateAssetKey: taskKey, TemplateVersion: 1,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BootstrapSeeds 幂等导入静态 catalog、首批行业工作流与 templates/docs。
|
||||
func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, templatesDir string, publishedBy int64) error {
|
||||
if pool == nil || !TablesReady(ctx, pool) {
|
||||
return nil
|
||||
}
|
||||
store := NewStore(pool)
|
||||
svc := NewService(pool, cas)
|
||||
|
||||
if templatesDir == "" {
|
||||
templatesDir = "templates/docs"
|
||||
}
|
||||
entries, err := os.ReadDir(templatesDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(ent.Name(), filepath.Ext(ent.Name()))
|
||||
data, readErr := os.ReadFile(filepath.Join(templatesDir, ent.Name()))
|
||||
if readErr != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
_, _ = svc.UploadTemplate(ctx, key, ent.Name(), data, ent.Name(), publishedBy)
|
||||
}
|
||||
|
||||
seeds := []CreateDefinitionInput{
|
||||
legacySingleStage("main", "商务与研发综合主线", "V0.1 分诊到 V0.6 阶段评审", "main", "main", "main_proposal", "商务研发综合方案", ""),
|
||||
legacySingleStage("product_manager", "智能产品经理", "系统需求规格书生成", "system_reqs", "system_reqs", "system_reqs", "系统需求规格书", ""),
|
||||
legacySingleStage("test_manager", "智能测试经理", "系统验证方案及计划", "system_test_plan", "test_manager", "system_test_plan", "系统验证方案及计划", "product_manager"),
|
||||
medicalDeviceWorkflow(),
|
||||
nonMedicalSoftwareWorkflow(),
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := store.UpsertPublishedSeed(ctx, seed, publishedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func medicalDeviceWorkflow() CreateDefinitionInput {
|
||||
return CreateDefinitionInput{
|
||||
Key: "medical_device_development",
|
||||
Name: "医疗器械研发",
|
||||
Description: "商务摸底 → 需求策划 → 设计开发 → 验证确认 → 设计转移",
|
||||
ProductDomain: "medical_device",
|
||||
Definition: VersionDefinition{
|
||||
SchemaVersion: 1,
|
||||
Stages: []StageDef{
|
||||
stage("biz_intake", "商务摸底", 0, doc("intake", "客户需求采集", "intake", "客户需求采集表")),
|
||||
stage("req_planning", "需求与法规策划", 1, doc("system_reqs", "系统需求规格书", "system_reqs", "系统需求规格书")),
|
||||
stage("design_dev", "设计开发", 2, doc("prelim_design", "初步设计", "prelim_design", "初步设计文档")),
|
||||
stage("verification", "设计验证/确认", 3, doc("test_manager", "系统验证方案", "system_test_plan", "系统验证方案及计划")),
|
||||
stage("transfer", "设计转移/发布", 4, doc("feasibility_reg", "法规可行性", "feasibility_reg", "法规可行性评估")),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nonMedicalSoftwareWorkflow() CreateDefinitionInput {
|
||||
return CreateDefinitionInput{
|
||||
Key: "non_medical_software",
|
||||
Name: "非医疗纯软件",
|
||||
Description: "机会范围 → 需求 → 架构 → 实现 → 测试验收 → 发布复盘",
|
||||
ProductDomain: "non_medical_software",
|
||||
Definition: VersionDefinition{
|
||||
SchemaVersion: 1,
|
||||
Stages: []StageDef{
|
||||
stage("opportunity", "机会与范围", 0, doc("intake", "范围说明", "scope_doc", "项目范围说明")),
|
||||
stage("requirements", "产品需求", 1, doc("user_requirements", "用户需求", "user_requirements", "用户需求说明")),
|
||||
stage("architecture", "架构与计划", 2, doc("feasibility_dev", "技术可行性", "feasibility_dev", "技术可行性分析")),
|
||||
stage("implementation", "实现", 3, doc("prelim_design", "架构设计", "architecture_design", "架构设计文档")),
|
||||
stage("testing", "测试与验收", 4, doc("test_manager", "测试计划", "test_plan", "测试计划")),
|
||||
stage("release", "发布复盘", 5, doc("research", "发布复盘", "release_review", "发布复盘报告")),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func stage(key, name string, pos int, tasks ...DocTaskDef) StageDef {
|
||||
return StageDef{Key: key, Name: name, Position: pos, DocTasks: tasks}
|
||||
}
|
||||
|
||||
func doc(key, title, docType, docTitle string) DocTaskDef {
|
||||
return DocTaskDef{
|
||||
Key: key, Title: title, Required: true, Blocking: true, DefaultEnabled: true,
|
||||
TemplateAssetKey: key, TemplateVersion: 1,
|
||||
OutputDocType: docType, OutputDocTitle: docTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// SeedFromMemoryCatalog 将内存 catalog 转为种子(测试用)。
|
||||
func SeedFromMemoryCatalog(cat projectworkflow.Catalog) []CreateDefinitionInput {
|
||||
if cat == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]CreateDefinitionInput, 0)
|
||||
for _, d := range cat.List() {
|
||||
out = append(out, legacySingleStage(d.Key, d.Name, d.Description, d.StageKey, d.TaskKey, d.OutputDocType, d.OutputDocTitle, d.RequiredUpstreamWorkflow))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
)
|
||||
|
||||
// DBCatalog 从已发布工作流版本构建 projectworkflow.Catalog(向后兼容扁平 Definition)。
|
||||
type DBCatalog struct {
|
||||
mu sync.RWMutex
|
||||
store *Store
|
||||
items map[string]*projectworkflow.Definition
|
||||
order []string
|
||||
}
|
||||
|
||||
func NewDBCatalog(ctx context.Context, store *Store) (*DBCatalog, error) {
|
||||
c := &DBCatalog{store: store, items: make(map[string]*projectworkflow.Definition)}
|
||||
if err := c.reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *DBCatalog) reload(ctx context.Context) error {
|
||||
versions, err := c.store.ListPublishedCatalogEntries(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make(map[string]*projectworkflow.Definition)
|
||||
order := make([]string, 0, len(versions))
|
||||
for _, v := range versions {
|
||||
def := flattenVersion(v)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
items[v.WorkflowKey] = def
|
||||
order = append(order, v.WorkflowKey)
|
||||
}
|
||||
sort.Strings(order)
|
||||
c.mu.Lock()
|
||||
c.items = items
|
||||
c.order = order
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func flattenVersion(v Version) *projectworkflow.Definition {
|
||||
if len(v.Definition.Stages) == 0 {
|
||||
return nil
|
||||
}
|
||||
st := v.Definition.Stages[0]
|
||||
taskKey, taskTitle, docType := "", "", ""
|
||||
if len(st.DocTasks) > 0 {
|
||||
dt := st.DocTasks[0]
|
||||
taskKey = dt.Key
|
||||
taskTitle = dt.OutputDocTitle
|
||||
docType = dt.OutputDocType
|
||||
if taskTitle == "" {
|
||||
taskTitle = dt.Title
|
||||
}
|
||||
}
|
||||
if taskKey == "" {
|
||||
taskKey = st.Key
|
||||
}
|
||||
name := v.WorkflowName
|
||||
if name == "" {
|
||||
name = v.WorkflowKey
|
||||
}
|
||||
return &projectworkflow.Definition{
|
||||
Key: v.WorkflowKey,
|
||||
Name: name,
|
||||
Description: v.Description,
|
||||
StageKey: st.Key,
|
||||
TaskKey: taskKey,
|
||||
RequiredUpstreamWorkflow: v.Definition.RequiredUpstreamWorkflow,
|
||||
OutputDocType: docType,
|
||||
OutputDocTitle: taskTitle,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DBCatalog) HasDefinitions() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.items) > 0
|
||||
}
|
||||
|
||||
func (c *DBCatalog) Refresh(ctx context.Context) error {
|
||||
return c.reload(ctx)
|
||||
}
|
||||
|
||||
func (c *DBCatalog) Get(workflowKey string) (*projectworkflow.Definition, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
d, ok := c.items[workflowKey]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cpy := *d
|
||||
return &cpy, true
|
||||
}
|
||||
|
||||
func (c *DBCatalog) List() []*projectworkflow.Definition {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]*projectworkflow.Definition, 0, len(c.order))
|
||||
for _, key := range c.order {
|
||||
if d, ok := c.items[key]; ok {
|
||||
cpy := *d
|
||||
out = append(out, &cpy)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *DBCatalog) Validate() error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
for _, def := range c.items {
|
||||
if def.RequiredUpstreamWorkflow != "" {
|
||||
if _, ok := c.items[def.RequiredUpstreamWorkflow]; !ok {
|
||||
return projectworkflow.ErrWorkflowNotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCatalog 优先 DB 已发布 catalog,否则回退静态 catalog。
|
||||
func NewCatalog(ctx context.Context, store *Store, fallback projectworkflow.Catalog) projectworkflow.Catalog {
|
||||
if store == nil || !store.enabled() {
|
||||
return fallback
|
||||
}
|
||||
dbCat, err := NewDBCatalog(ctx, store)
|
||||
if err != nil || !dbCat.HasDefinitions() {
|
||||
return fallback
|
||||
}
|
||||
return dbCat
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package workflowdef
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotAvailable = errors.New("workflowdef: database not available")
|
||||
ErrNotFound = errors.New("workflowdef: not found")
|
||||
ErrInvalidInput = errors.New("workflowdef: invalid input")
|
||||
ErrDraftExists = errors.New("workflowdef: draft version already exists")
|
||||
ErrNotDraft = errors.New("workflowdef: only draft versions are editable")
|
||||
ErrPublishedImmutable = errors.New("workflowdef: published version is immutable")
|
||||
ErrValidationFailed = errors.New("workflowdef: validation failed")
|
||||
ErrTemplateNotReady = errors.New("workflowdef: template version not ready")
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package workflowdef_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func openPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skip(err)
|
||||
}
|
||||
if !workflowdef.TablesReady(ctx, pool) {
|
||||
pool.Close()
|
||||
t.Skip("workflowdef tables not migrated")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestPublishVersion_ImmutableAfterPublish(t *testing.T) {
|
||||
pool := openPool(t)
|
||||
if pool == nil {
|
||||
return
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
svc := workflowdef.NewService(pool, nil)
|
||||
key := "test_wf_" + time.Now().Format("150405")
|
||||
def, ver, err := svc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{
|
||||
Key: key, Name: "测试工作流",
|
||||
Definition: workflowdef.VersionDefinition{
|
||||
SchemaVersion: 1,
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "s1", Position: 0,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "t1", DefaultEnabled: true, OutputDocType: "d1", OutputDocTitle: "D1",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
published, err := svc.PublishVersion(ctx, def.ID, ver.Version, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if published.Status != workflowdef.StatusPublished {
|
||||
t.Fatalf("expected published, got %s", published.Status)
|
||||
}
|
||||
_, err = svc.UpdateDraft(ctx, def.ID, ver.Version, published.Definition)
|
||||
if err == nil {
|
||||
t.Fatal("expected update on published version to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/storage"
|
||||
)
|
||||
|
||||
// Service 工作流定义与模板领域服务。
|
||||
type Service struct {
|
||||
store *Store
|
||||
cas *storage.CAS
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, cas *storage.CAS) *Service {
|
||||
if pool == nil {
|
||||
return &Service{}
|
||||
}
|
||||
return &Service{store: NewStore(pool), cas: cas}
|
||||
}
|
||||
|
||||
func (s *Service) Available() bool {
|
||||
return s != nil && s.store != nil && s.store.enabled()
|
||||
}
|
||||
|
||||
func (s *Service) Store() *Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
func (s *Service) ListDefinitions(ctx context.Context) ([]Definition, error) {
|
||||
return s.store.ListDefinitions(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetDefinition(ctx context.Context, id int64) (*Definition, error) {
|
||||
return s.store.GetDefinitionByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateDefinition(ctx context.Context, in CreateDefinitionInput, createdBy int64) (*Definition, *Version, error) {
|
||||
res := ValidateDefinition(in.Definition, s.templateReady(ctx), s.upstreamExists(ctx))
|
||||
if !res.Valid {
|
||||
return nil, nil, ErrValidationFailed
|
||||
}
|
||||
return s.store.CreateDefinition(ctx, in, createdBy)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateDraft(ctx context.Context, workflowID int64, version int, def VersionDefinition) (*Version, error) {
|
||||
res := ValidateDefinition(def, s.templateReady(ctx), s.upstreamExists(ctx))
|
||||
if !res.Valid {
|
||||
return nil, ErrValidationFailed
|
||||
}
|
||||
return s.store.UpdateDraftVersion(ctx, workflowID, version, def)
|
||||
}
|
||||
|
||||
func (s *Service) ValidateVersion(ctx context.Context, workflowID int64, version int) (ValidationResult, error) {
|
||||
v, err := s.store.GetVersion(ctx, workflowID, version)
|
||||
if err != nil {
|
||||
return ValidationResult{}, err
|
||||
}
|
||||
return ValidateDefinition(v.Definition, s.templateReady(ctx), s.upstreamExists(ctx)), nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishVersion(ctx context.Context, workflowID int64, version int, publishedBy int64) (*Version, error) {
|
||||
res, err := s.ValidateVersion(ctx, workflowID, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.Valid {
|
||||
return nil, ErrValidationFailed
|
||||
}
|
||||
return s.store.PublishVersion(ctx, workflowID, version, publishedBy)
|
||||
}
|
||||
|
||||
func (s *Service) RetireVersion(ctx context.Context, workflowID int64, version int) (*Version, error) {
|
||||
return s.store.RetireVersion(ctx, workflowID, version)
|
||||
}
|
||||
|
||||
func (s *Service) ListVersions(ctx context.Context, workflowID int64) ([]Version, error) {
|
||||
return s.store.ListVersions(ctx, workflowID)
|
||||
}
|
||||
|
||||
func (s *Service) ListTemplates(ctx context.Context) ([]TemplateAsset, error) {
|
||||
return s.store.ListTemplateAssets(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ListTemplateVersions(ctx context.Context, assetKey string) ([]TemplateVersion, error) {
|
||||
return s.store.ListTemplateVersions(ctx, assetKey)
|
||||
}
|
||||
|
||||
var allowedTemplateExt = map[string]string{
|
||||
".md": "text/markdown",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
|
||||
// UploadTemplate 上传模板文件并创建 template_version(MVP:扩展名白名单后标记 ready)。
|
||||
func (s *Service) UploadTemplate(ctx context.Context, assetKey, name string, data []byte, fileName string, createdBy int64) (*TemplateVersion, error) {
|
||||
if !s.Available() || s.cas == nil {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
mime, ok := allowedTemplateExt[ext]
|
||||
if !ok {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if len(data) == 0 || len(data) > 32*1024*1024 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
ref, err := s.cas.StoreFile(ctx, 0, createdBy, fileName, "template", data, mime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetID, err := s.store.UpsertTemplateAsset(ctx, assetKey, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver, err := s.store.NextTemplateVersion(ctx, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanStatus := ScanReady
|
||||
if ext == ".docx" || ext == ".xlsx" || ext == ".pptx" {
|
||||
scanStatus = ScanPending
|
||||
}
|
||||
tv, err := s.store.CreateTemplateVersion(ctx, assetID, ver, ref.BlobSHA256, mime, int64(len(data)), scanStatus, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tv.AssetKey = assetKey
|
||||
return tv, nil
|
||||
}
|
||||
|
||||
func (s *Service) templateReady(ctx context.Context) templateReadyChecker {
|
||||
return func(assetKey string, version int) bool {
|
||||
if assetKey == "" {
|
||||
return true
|
||||
}
|
||||
if version <= 0 {
|
||||
version = 1
|
||||
}
|
||||
return s.store.IsTemplateReady(ctx, assetKey, version)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) upstreamExists(ctx context.Context) upstreamExistsChecker {
|
||||
return func(workflowKey string) bool {
|
||||
if workflowKey == "" {
|
||||
return true
|
||||
}
|
||||
d, err := s.store.GetDefinitionByKey(ctx, workflowKey)
|
||||
return err == nil && d != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TablesReady 检查 Phase 2 表是否已迁移。
|
||||
func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool {
|
||||
if pool == nil {
|
||||
return false
|
||||
}
|
||||
var ok bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'workflow_definitions'
|
||||
)
|
||||
`).Scan(&ok)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// Store PostgreSQL 工作流定义与模板存储。
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
func (s *Store) enabled() bool {
|
||||
return s != nil && s.pool != nil
|
||||
}
|
||||
|
||||
func hashDefinition(def VersionDefinition) (string, error) {
|
||||
raw, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func scanVersion(row pgx.Row) (*Version, error) {
|
||||
var v Version
|
||||
var defRaw []byte
|
||||
var pubBy, createdBy *int64
|
||||
err := row.Scan(
|
||||
&v.ID, &v.WorkflowID, &v.Version, &v.Status, &v.SchemaVersion,
|
||||
&defRaw, &v.DefinitionHash, &pubBy, &v.PublishedAt, &createdBy,
|
||||
&v.CreatedAt, &v.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.PublishedBy = pubBy
|
||||
v.CreatedBy = createdBy
|
||||
if len(defRaw) > 0 {
|
||||
_ = json.Unmarshal(defRaw, &v.Definition)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// ListDefinitions 列出全部工作流定义。
|
||||
func (s *Store) ListDefinitions(ctx context.Context) ([]Definition, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at
|
||||
FROM workflow_definitions ORDER BY def_key ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Definition, 0)
|
||||
for rows.Next() {
|
||||
var d Definition
|
||||
if err := rows.Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetDefinitionByKey 按 key 查询定义。
|
||||
func (s *Store) GetDefinitionByKey(ctx context.Context, key string) (*Definition, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
var d Definition
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at
|
||||
FROM workflow_definitions WHERE def_key = $1
|
||||
`, key).Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// GetDefinitionByID 按 ID 查询定义。
|
||||
func (s *Store) GetDefinitionByID(ctx context.Context, id int64) (*Definition, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
var d Definition
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at
|
||||
FROM workflow_definitions WHERE id = $1
|
||||
`, id).Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// CreateDefinition 创建定义及首个草稿版本。
|
||||
func (s *Store) CreateDefinition(ctx context.Context, in CreateDefinitionInput, createdBy int64) (*Definition, *Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, nil, ErrNotAvailable
|
||||
}
|
||||
if in.Key == "" || in.Name == "" {
|
||||
return nil, nil, ErrInvalidInput
|
||||
}
|
||||
defHash, err := hashDefinition(in.Definition)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defRaw, _ := json.Marshal(in.Definition)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var defID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO workflow_definitions (def_key, name, description, product_domain)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id
|
||||
`, in.Key, in.Name, in.Description, in.ProductDomain).Scan(&defID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("insert definition: %w", err)
|
||||
}
|
||||
|
||||
var ver Version
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO workflow_versions (workflow_id, version, status, schema_version, definition_json, definition_hash, created_by)
|
||||
VALUES ($1, 1, 'draft', $2, $3, $4, $5)
|
||||
RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
`, defID, in.Definition.SchemaVersion, defRaw, defHash, createdBy).Scan(
|
||||
&ver.ID, &ver.WorkflowID, &ver.Version, &ver.Status, &ver.SchemaVersion,
|
||||
&defRaw, &ver.DefinitionHash, &ver.PublishedBy, &ver.PublishedAt, &ver.CreatedBy,
|
||||
&ver.CreatedAt, &ver.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = json.Unmarshal(defRaw, &ver.Definition)
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
d, err := s.GetDefinitionByID(ctx, defID)
|
||||
return d, &ver, err
|
||||
}
|
||||
|
||||
// GetVersion 获取指定版本。
|
||||
func (s *Store) GetVersion(ctx context.Context, workflowID int64, version int) (*Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
FROM workflow_versions WHERE workflow_id = $1 AND version = $2
|
||||
`, workflowID, version)
|
||||
v, err := scanVersion(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// ListVersions 列出工作流全部版本。
|
||||
func (s *Store) ListVersions(ctx context.Context, workflowID int64) ([]Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
FROM workflow_versions WHERE workflow_id = $1 ORDER BY version DESC
|
||||
`, workflowID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Version, 0)
|
||||
for rows.Next() {
|
||||
v, err := scanVersion(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateDraftVersion 更新草稿版本定义。
|
||||
func (s *Store) UpdateDraftVersion(ctx context.Context, workflowID int64, version int, def VersionDefinition) (*Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
existing, err := s.GetVersion(ctx, workflowID, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.Status != StatusDraft {
|
||||
return nil, ErrNotDraft
|
||||
}
|
||||
defHash, err := hashDefinition(def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defRaw, _ := json.Marshal(def)
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE workflow_versions SET definition_json = $3, definition_hash = $4, schema_version = $5, updated_at = now()
|
||||
WHERE workflow_id = $1 AND version = $2 AND status = 'draft'
|
||||
RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
`, workflowID, version, defRaw, defHash, def.SchemaVersion)
|
||||
return scanVersion(row)
|
||||
}
|
||||
|
||||
// PublishVersion 发布草稿版本(不可变)。
|
||||
func (s *Store) PublishVersion(ctx context.Context, workflowID int64, version int, publishedBy int64) (*Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM workflow_versions WHERE workflow_id = $1 AND version = $2`, workflowID, version).Scan(&status); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if status != StatusDraft {
|
||||
return nil, ErrNotDraft
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE workflow_versions SET status = 'published', published_by = $3, published_at = now(), updated_at = now()
|
||||
WHERE workflow_id = $1 AND version = $2 AND status = 'draft'
|
||||
RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
`, workflowID, version, publishedBy)
|
||||
v, err := scanVersion(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE workflow_definitions SET latest_published_version = $2, updated_at = now() WHERE id = $1
|
||||
`, workflowID, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RetireVersion 退役已发布版本。
|
||||
func (s *Store) RetireVersion(ctx context.Context, workflowID int64, version int) (*Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE workflow_versions SET status = 'retired', updated_at = now()
|
||||
WHERE workflow_id = $1 AND version = $2 AND status = 'published'
|
||||
RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash,
|
||||
published_by, published_at, created_by, created_at, updated_at
|
||||
`, workflowID, version)
|
||||
v, err := scanVersion(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// ListPublishedCatalogEntries 返回全部 published 版本(catalog 用)。
|
||||
func (s *Store) ListPublishedCatalogEntries(ctx context.Context) ([]Version, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT v.id, v.workflow_id, v.version, v.status, v.schema_version, v.definition_json, v.definition_hash,
|
||||
v.published_by, v.published_at, v.created_by, v.created_at, v.updated_at,
|
||||
d.def_key, d.name, d.description
|
||||
FROM workflow_versions v
|
||||
JOIN workflow_definitions d ON d.id = v.workflow_id
|
||||
WHERE v.status = 'published'
|
||||
ORDER BY d.def_key ASC, v.version DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[int64]bool{}
|
||||
out := make([]Version, 0)
|
||||
for rows.Next() {
|
||||
var v Version
|
||||
var defRaw []byte
|
||||
var pubBy, createdBy *int64
|
||||
var wfKey, wfName, wfDesc string
|
||||
if err := rows.Scan(
|
||||
&v.ID, &v.WorkflowID, &v.Version, &v.Status, &v.SchemaVersion,
|
||||
&defRaw, &v.DefinitionHash, &pubBy, &v.PublishedAt, &createdBy,
|
||||
&v.CreatedAt, &v.UpdatedAt, &wfKey, &wfName, &wfDesc,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[v.WorkflowID] {
|
||||
continue
|
||||
}
|
||||
seen[v.WorkflowID] = true
|
||||
v.WorkflowKey = wfKey
|
||||
v.WorkflowName = wfName
|
||||
v.Description = wfDesc
|
||||
v.PublishedBy = pubBy
|
||||
v.CreatedBy = createdBy
|
||||
_ = json.Unmarshal(defRaw, &v.Definition)
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertPublishedSeed 幂等写入已发布种子(bootstrap)。
|
||||
func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInput, publishedBy int64) error {
|
||||
if !s.enabled() {
|
||||
return ErrNotAvailable
|
||||
}
|
||||
existing, err := s.GetDefinitionByKey(ctx, in.Key)
|
||||
if err == nil && existing != nil && existing.LatestPublishedVersion > 0 {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
defHash, err := hashDefinition(in.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defRaw, _ := json.Marshal(in.Definition)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var defID int64
|
||||
if existing != nil {
|
||||
defID = existing.ID
|
||||
} else {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO workflow_definitions (def_key, name, description, product_domain, latest_published_version)
|
||||
VALUES ($1, $2, $3, $4, 1) RETURNING id
|
||||
`, in.Key, in.Name, in.Description, in.ProductDomain).Scan(&defID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO workflow_versions (workflow_id, version, status, schema_version, definition_json, definition_hash, published_by, published_at, created_by)
|
||||
VALUES ($1, 1, 'published', $2, $3, $4, $5, now(), $5)
|
||||
ON CONFLICT (workflow_id, version) DO NOTHING
|
||||
`, defID, in.Definition.SchemaVersion, defRaw, defHash, publishedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE workflow_definitions SET latest_published_version = 1, updated_at = now() WHERE id = $1
|
||||
`, defID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Template store methods
|
||||
|
||||
func (s *Store) UpsertTemplateAsset(ctx context.Context, assetKey, name string) (int64, error) {
|
||||
if !s.enabled() {
|
||||
return 0, ErrNotAvailable
|
||||
}
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO template_assets (asset_key, name) VALUES ($1, $2)
|
||||
ON CONFLICT (asset_key) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id
|
||||
`, assetKey, name).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateTemplateVersion(ctx context.Context, assetID int64, version int, sha256, mime string, size int64, scanStatus string, createdBy int64) (*TemplateVersion, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
var tv TemplateVersion
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO template_versions (asset_id, version, blob_sha256, mime_type, size_bytes, scan_status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (asset_id, version) DO UPDATE SET
|
||||
blob_sha256 = EXCLUDED.blob_sha256,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
scan_status = EXCLUDED.scan_status
|
||||
RETURNING id, asset_id, version, blob_sha256, mime_type, size_bytes, scan_status, created_at
|
||||
`, assetID, version, sha256, mime, size, scanStatus, createdBy).Scan(
|
||||
&tv.ID, &tv.AssetID, &tv.Version, &tv.BlobSHA256, &tv.MimeType, &tv.SizeBytes, &tv.ScanStatus, &tv.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tv, nil
|
||||
}
|
||||
|
||||
func (s *Store) IsTemplateReady(ctx context.Context, assetKey string, version int) bool {
|
||||
if !s.enabled() {
|
||||
return false
|
||||
}
|
||||
var ready bool
|
||||
_ = s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM template_versions tv
|
||||
JOIN template_assets ta ON ta.id = tv.asset_id
|
||||
WHERE ta.asset_key = $1 AND tv.version = $2 AND tv.scan_status = 'ready'
|
||||
)
|
||||
`, assetKey, version).Scan(&ready)
|
||||
return ready
|
||||
}
|
||||
|
||||
func (s *Store) ListTemplateAssets(ctx context.Context) ([]TemplateAsset, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, asset_key, name, status, created_at, updated_at FROM template_assets ORDER BY asset_key ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]TemplateAsset, 0)
|
||||
for rows.Next() {
|
||||
var a TemplateAsset
|
||||
if err := rows.Scan(&a.ID, &a.AssetKey, &a.Name, &a.Status, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListTemplateVersions(ctx context.Context, assetKey string) ([]TemplateVersion, error) {
|
||||
if !s.enabled() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT tv.id, tv.asset_id, ta.asset_key, tv.version, tv.blob_sha256, tv.mime_type, tv.size_bytes, tv.scan_status, tv.created_at
|
||||
FROM template_versions tv
|
||||
JOIN template_assets ta ON ta.id = tv.asset_id
|
||||
WHERE ta.asset_key = $1 ORDER BY tv.version DESC
|
||||
`, assetKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]TemplateVersion, 0)
|
||||
for rows.Next() {
|
||||
var tv TemplateVersion
|
||||
if err := rows.Scan(&tv.ID, &tv.AssetID, &tv.AssetKey, &tv.Version, &tv.BlobSHA256, &tv.MimeType, &tv.SizeBytes, &tv.ScanStatus, &tv.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) NextTemplateVersion(ctx context.Context, assetID int64) (int, error) {
|
||||
if !s.enabled() {
|
||||
return 0, ErrNotAvailable
|
||||
}
|
||||
var next int
|
||||
err := s.pool.QueryRow(ctx, `SELECT COALESCE(MAX(version), 0) + 1 FROM template_versions WHERE asset_id = $1`, assetID).Scan(&next)
|
||||
return next, err
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package workflowdef
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusPublished = "published"
|
||||
StatusRetired = "retired"
|
||||
|
||||
ScanPending = "pending"
|
||||
ScanReady = "ready"
|
||||
ScanRejected = "rejected"
|
||||
)
|
||||
|
||||
// DocTaskDef 阶段内文档任务定义。
|
||||
type DocTaskDef struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
Blocking bool `json:"blocking"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
TemplateAssetKey string `json:"template_asset_key,omitempty"`
|
||||
TemplateVersion int `json:"template_version,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
}
|
||||
|
||||
// StageDef 线性阶段定义。
|
||||
type StageDef struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Optional bool `json:"optional"`
|
||||
ManualGate bool `json:"manual_gate"`
|
||||
DocTasks []DocTaskDef `json:"doc_tasks"`
|
||||
}
|
||||
|
||||
// VersionDefinition 工作流版本完整快照(JSONB)。
|
||||
type VersionDefinition struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
Stages []StageDef `json:"stages"`
|
||||
}
|
||||
|
||||
// Definition 工作流逻辑标识。
|
||||
type Definition struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
ProductDomain string `json:"product_domain,omitempty"`
|
||||
LatestPublishedVersion int `json:"latest_published_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Version 工作流版本记录。
|
||||
type Version struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflow_id"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
WorkflowName string `json:"workflow_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Definition VersionDefinition `json:"definition"`
|
||||
DefinitionHash string `json:"definition_hash"`
|
||||
PublishedBy *int64 `json:"published_by,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ValidationIssue 校验问题。
|
||||
type ValidationIssue struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationResult 校验结果。
|
||||
type ValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
// CreateDefinitionInput 新建工作流定义。
|
||||
type CreateDefinitionInput struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ProductDomain string `json:"product_domain,omitempty"`
|
||||
Definition VersionDefinition `json:"definition"`
|
||||
}
|
||||
|
||||
// TemplateAsset 模板逻辑资产。
|
||||
type TemplateAsset struct {
|
||||
ID int64 `json:"id"`
|
||||
AssetKey string `json:"asset_key"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TemplateVersion 模板版本。
|
||||
type TemplateVersion struct {
|
||||
ID int64 `json:"id"`
|
||||
AssetID int64 `json:"asset_id"`
|
||||
AssetKey string `json:"asset_key,omitempty"`
|
||||
Version int `json:"version"`
|
||||
BlobSHA256 string `json:"blob_sha256"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ScanStatus string `json:"scan_status"`
|
||||
VariableSchema []string `json:"variable_schema"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type templateReadyChecker func(assetKey string, version int) bool
|
||||
type upstreamExistsChecker func(workflowKey string) bool
|
||||
|
||||
// ValidateDefinition 对工作流版本定义做确定性校验。
|
||||
func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, upstreamExists upstreamExistsChecker) ValidationResult {
|
||||
issues := make([]ValidationIssue, 0)
|
||||
if def.SchemaVersion <= 0 {
|
||||
def.SchemaVersion = 1
|
||||
}
|
||||
if len(def.Stages) == 0 {
|
||||
issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "至少需要一个阶段"})
|
||||
return ValidationResult{Valid: false, Issues: issues}
|
||||
}
|
||||
|
||||
stageKeys := map[string]struct{}{}
|
||||
taskKeys := map[string]struct{}{}
|
||||
enabledStageCount := 0
|
||||
|
||||
sorted := append([]StageDef(nil), def.Stages...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Position < sorted[j].Position })
|
||||
|
||||
for i, st := range sorted {
|
||||
if st.Key == "" {
|
||||
issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "阶段 key 不能为空", Field: fmt.Sprintf("stages[%d].key", i)})
|
||||
continue
|
||||
}
|
||||
if _, dup := stageKeys[st.Key]; dup {
|
||||
issues = append(issues, ValidationIssue{Code: "DUPLICATE_STAGE_KEY", Message: fmt.Sprintf("重复阶段 key: %s", st.Key), Field: st.Key})
|
||||
}
|
||||
stageKeys[st.Key] = struct{}{}
|
||||
if st.Position != i {
|
||||
issues = append(issues, ValidationIssue{Code: "STAGE_POSITION_GAP", Message: fmt.Sprintf("阶段 %s 顺序应为 %d", st.Key, i), Field: st.Key})
|
||||
}
|
||||
|
||||
enabledTasks := 0
|
||||
for j, dt := range st.DocTasks {
|
||||
if dt.Key == "" {
|
||||
issues = append(issues, ValidationIssue{Code: "TASK_KEY_REQUIRED", Message: "文档任务 key 不能为空", Field: fmt.Sprintf("%s.doc_tasks[%d]", st.Key, j)})
|
||||
continue
|
||||
}
|
||||
if _, dup := taskKeys[dt.Key]; dup {
|
||||
issues = append(issues, ValidationIssue{Code: "DUPLICATE_TASK_KEY", Message: fmt.Sprintf("重复任务 key: %s", dt.Key), Field: dt.Key})
|
||||
}
|
||||
taskKeys[dt.Key] = struct{}{}
|
||||
if dt.OutputDocType == "" || dt.OutputDocTitle == "" {
|
||||
issues = append(issues, ValidationIssue{Code: "TASK_OUTPUT_REQUIRED", Message: fmt.Sprintf("任务 %s 需 output_doc_type/title", dt.Key), Field: dt.Key})
|
||||
}
|
||||
if dt.DefaultEnabled {
|
||||
enabledTasks++
|
||||
}
|
||||
if dt.TemplateAssetKey != "" && tplReady != nil {
|
||||
ver := dt.TemplateVersion
|
||||
if ver <= 0 {
|
||||
ver = 1
|
||||
}
|
||||
if !tplReady(dt.TemplateAssetKey, ver) {
|
||||
issues = append(issues, ValidationIssue{Code: "TEMPLATE_NOT_READY", Message: fmt.Sprintf("模板 %s v%d 不可用", dt.TemplateAssetKey, ver), Field: dt.Key})
|
||||
}
|
||||
}
|
||||
}
|
||||
if !st.Optional {
|
||||
if enabledTasks == 0 && !st.ManualGate {
|
||||
issues = append(issues, ValidationIssue{Code: "STAGE_EMPTY", Message: fmt.Sprintf("阶段 %s 无启用文档且未设 manual_gate", st.Key), Field: st.Key})
|
||||
} else {
|
||||
enabledStageCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabledStageCount == 0 {
|
||||
issues = append(issues, ValidationIssue{Code: "NO_ENABLED_STAGE", Message: "至少一个非 optional 阶段需有启用文档或 manual_gate"})
|
||||
}
|
||||
|
||||
up := strings.TrimSpace(def.RequiredUpstreamWorkflow)
|
||||
if up != "" && upstreamExists != nil && !upstreamExists(up) {
|
||||
issues = append(issues, ValidationIssue{Code: "UPSTREAM_NOT_FOUND", Message: fmt.Sprintf("上游工作流 %s 不存在", up), Field: "required_upstream_workflow"})
|
||||
}
|
||||
|
||||
return ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package workflowdef_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestValidateDefinition_RejectsEmptyStages(t *testing.T) {
|
||||
res := workflowdef.ValidateDefinition(workflowdef.VersionDefinition{}, nil, nil)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid for empty stages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinition_AcceptsMinimalWorkflow(t *testing.T) {
|
||||
def := workflowdef.VersionDefinition{
|
||||
SchemaVersion: 1,
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "s1", Name: "阶段1", Position: 0,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "t1", Title: "任务1", DefaultEnabled: true,
|
||||
OutputDocType: "doc1", OutputDocTitle: "文档1",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
res := workflowdef.ValidateDefinition(def, func(string, int) bool { return true }, nil)
|
||||
if !res.Valid {
|
||||
t.Fatalf("expected valid, got issues: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinition_RejectsMissingTemplate(t *testing.T) {
|
||||
def := workflowdef.VersionDefinition{
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "s1", Position: 0,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "t1", DefaultEnabled: true, TemplateAssetKey: "missing",
|
||||
OutputDocType: "d", OutputDocTitle: "D",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
res := workflowdef.ValidateDefinition(def, func(string, int) bool { return false }, nil)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid when template not ready")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user