DB-backed repo_path_allowlist reader (deny-all until admin seeds), per-prefix admin test_commands (goose 00075), execution evidence table (goose 00074), isolated git-worktree patch executor with command allowlist/time/output caps and failure-stops evidence; controlled_patch creation/preview unlocked only with an allowlisted repo binding (projectworkflow gate + catalog flag); creator-only server endpoints for bindings/executions; merge/push/deploy stay behind a typed separate-authorization boundary. Module boundary: app->swboot composition import registered.
500 lines
13 KiB
Go
500 lines
13 KiB
Go
package projectworkflow
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态)
|
|
// =============================================================================
|
|
|
|
type memStore struct {
|
|
mu sync.RWMutex
|
|
projects map[int64]*Project
|
|
members map[int64]map[int64]struct{} // projectID -> set of active member userIDs
|
|
byClientKey map[string]int64 // creatorID|clientKey -> projectID
|
|
workflows map[int64]map[string]*ProjectWorkflow
|
|
runs map[string]*WorkflowRun
|
|
bindings map[int64]memBinding
|
|
audits []*WorkflowActivationAudit
|
|
lineages []*DocumentLineage
|
|
nextProjID int64
|
|
nextWFID int64
|
|
nextRunID int64
|
|
nextLineageID int64
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
m := &memStore{
|
|
projects: make(map[int64]*Project),
|
|
members: make(map[int64]map[int64]struct{}),
|
|
byClientKey: make(map[string]int64),
|
|
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
|
|
if p.CreatorUserID > 0 {
|
|
if m.members[p.ID] == nil {
|
|
m.members[p.ID] = make(map[int64]struct{})
|
|
}
|
|
m.members[p.ID][p.CreatorUserID] = struct{}{}
|
|
if key := strings.TrimSpace(p.ClientKey); key != "" {
|
|
m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN).
|
|
func (m *memStore) AddProjectMember(projectID, userID int64) {
|
|
if projectID <= 0 || userID <= 0 {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.members[projectID] == nil {
|
|
m.members[projectID] = make(map[int64]struct{})
|
|
}
|
|
m.members[projectID][userID] = struct{}{}
|
|
}
|
|
|
|
func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
|
|
clientKey = strings.TrimSpace(clientKey)
|
|
if creatorUserID <= 0 || clientKey == "" {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)]
|
|
if !ok {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
p, ok := m.projects[id]
|
|
if !ok {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
cpy := *p
|
|
return &cpy, nil
|
|
}
|
|
|
|
func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
|
|
name = strings.TrimSpace(name)
|
|
if creatorUserID <= 0 || name == "" {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
want := strings.ToLower(name)
|
|
for _, p := range m.projects {
|
|
if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
|
cpy := *p
|
|
return &cpy, nil
|
|
}
|
|
}
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
|
|
func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
want := strings.ToLower(name)
|
|
for _, p := range m.projects {
|
|
if strings.EqualFold(p.Status, "demo_retired") {
|
|
continue
|
|
}
|
|
if strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
|
cpy := *p
|
|
return &cpy, nil
|
|
}
|
|
}
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
|
|
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 {
|
|
cpy := *p
|
|
list = append(list, &cpy)
|
|
continue
|
|
}
|
|
if members, ok := m.members[p.ID]; ok {
|
|
if _, isMember := members[userID]; isMember {
|
|
cpy := *p
|
|
list = append(list, &cpy)
|
|
}
|
|
}
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
list := make([]*Project, 0, len(m.projects))
|
|
for _, p := range m.projects {
|
|
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 := ""
|
|
if proj != nil {
|
|
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) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var list []*DocumentLineage
|
|
for _, l := range m.lineages {
|
|
if l.ProjectID == projectID && 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
|
|
}
|
|
|
|
func (m *memStore) SaveProjectSourceBinding(ctx context.Context, projectID int64, repoPath, permissionScope string, createdBy int64) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.bindings == nil {
|
|
m.bindings = make(map[int64]memBinding)
|
|
}
|
|
m.bindings[projectID] = memBinding{RepoPath: repoPath, PermissionScope: permissionScope, CreatedBy: createdBy}
|
|
return nil
|
|
}
|
|
|
|
type memBinding struct {
|
|
RepoPath string
|
|
PermissionScope string
|
|
CreatedBy int64
|
|
}
|