package docgov import ( "context" "encoding/json" "fmt" "log" "log/slog" "strings" "time" ) // CreateOrGetDocument 创建或获取受控文档记录 func (e *GovernanceEngine) CreateOrGetDocument( ctx context.Context, docID string, projectID int64, taskKey string, title string, stage string, workDir string, authorID int64, authorName string, reviewerID int64, reviewerName string, approverID int64, approverName string, ) (*DocumentRecord, error) { // T20 B3: doc 级锁;创建/装载只阻塞同文档。 g := e.acquireDocLock(docID) defer g.Release() now := time.Now() if doc, exists := e.memDocsGet(docID); exists { return doc, nil } // 内存没有查 DB if e.pool != nil { if docFromDB, err := e.loadDocumentFromDBLocked(ctx, docID); err == nil && docFromDB != nil { return docFromDB, nil } } doc := &DocumentRecord{ DocID: docID, ProjectID: projectID, TaskKey: taskKey, Title: title, Stage: stage, Version: 1, CurrentState: StateDrafting, IsLocked: false, IsTemplateOnly: true, WorkDir: workDir, AuthorID: authorID, AuthorName: authorName, ReviewerID: reviewerID, ReviewerName: reviewerName, ApproverID: approverID, ApproverName: approverName, Config: DefaultDocumentConfig(), CreatedAt: now, UpdatedAt: now, } // 若有 DB 连接,写 DB 并同步 if e.pool != nil { if projectID > 0 { _, _ = e.pool.Exec(ctx, ` INSERT INTO projects (id, code, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING `, projectID, fmt.Sprintf("PROJ_%d", projectID), "受控工程研发项目") } ensureUser := func(uid int64, uname, urole string) { if uid <= 0 { return } if _, err := e.pool.Exec(ctx, ` INSERT INTO users (id, account_id, name, role) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING `, uid, fmt.Sprintf("acc_%d", uid), uname, urole); err != nil { log.Printf("[docgov] ensureUser id=%d role=%s failed: %v", uid, urole, err) } } ensureUser(doc.AuthorID, doc.AuthorName, "engineer") ensureUser(doc.ReviewerID, doc.ReviewerName, "regulatory") ensureUser(doc.ApproverID, doc.ApproverName, "director") cfgJSON, _ := json.Marshal(doc.Config) query := ` INSERT INTO document_records ( doc_id, project_id, task_key, title, stage, version, current_state, is_locked, is_template_only, work_dir, author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, config, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (doc_id) DO UPDATE SET title = EXCLUDED.title, stage = EXCLUDED.stage, updated_at = EXCLUDED.updated_at RETURNING id, current_state, is_locked, is_template_only, author_claimed, reviewer_claimed, approver_claimed; ` var id int64 var currState string var isLocked, isTmpl, authClaim, revClaim, appClaim bool err := e.pool.QueryRow(ctx, query, doc.DocID, doc.ProjectID, doc.TaskKey, doc.Title, doc.Stage, doc.Version, string(doc.CurrentState), doc.IsLocked, doc.IsTemplateOnly, doc.WorkDir, nullIfZero(doc.AuthorID), doc.AuthorName, nullIfZero(doc.ReviewerID), doc.ReviewerName, nullIfZero(doc.ApproverID), doc.ApproverName, cfgJSON, doc.CreatedAt, doc.UpdatedAt, ).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim) if err != nil { return nil, fmt.Errorf("persist document ledger: %w", err) } doc.ID = id doc.CurrentState = DocumentState(currState) doc.IsLocked = isLocked doc.IsTemplateOnly = isTmpl doc.AuthorClaimed = authClaim doc.ReviewerClaimed = revClaim doc.ApproverClaimed = appClaim } e.memDocsPut(docID, doc) return doc, nil } // LockDocumentDuty 将文档职责钉住为实例治理结论;heal/revoke 跳过 duty_locked 行。 // 不改写 author/reviewer/approver 列——那些在 CreateOrGetDocument 时已由调用方写入。 func (e *GovernanceEngine) LockDocumentDuty(ctx context.Context, docID string, dutyPolicy string) error { docID = strings.TrimSpace(docID) if docID == "" { return fmt.Errorf("docgov: empty doc_id") } dutyPolicy = strings.TrimSpace(dutyPolicy) g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return err } now := time.Now() doc.DutyLocked = true doc.DutyPolicy = dutyPolicy doc.UpdatedAt = now if e.pool != nil { tag, execErr := e.pool.Exec(ctx, ` UPDATE document_records SET duty_locked = true, duty_policy = $2, updated_at = $3 WHERE doc_id = $1 `, docID, dutyPolicy, now) if execErr != nil { return fmt.Errorf("docgov: lock document duty: %w", execErr) } if tag.RowsAffected() == 0 { return ErrDocumentNotFound } } return nil } // MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态。 // 未预加载时从 DB 补偿装入;已是目标哈希则幂等成功,保证 repair 重试也能同步记录。 func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error { g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return err } doc.ContentSHA256 = contentSHA256 doc.IsTemplateOnly = isTemplateOnly doc.UpdatedAt = time.Now() if e.pool != nil { tag, execErr := e.pool.Exec(ctx, ` UPDATE document_records SET content_sha256 = $1, is_template_only = $2, work_dir = $3, updated_at = $4 WHERE doc_id = $5 `, doc.ContentSHA256, doc.IsTemplateOnly, doc.WorkDir, doc.UpdatedAt, docID) if execErr != nil { return fmt.Errorf("docgov: mark content updated: %w", execErr) } if tag.RowsAffected() == 0 { return ErrDocumentNotFound } } return nil } func (e *GovernanceEngine) ensureDocumentLocked(ctx context.Context, docID string) (*DocumentRecord, error) { if doc, exists := e.memDocsGet(docID); exists { return doc, nil } if e.pool != nil { doc, err := e.loadDocumentFromDBLocked(ctx, docID) if err == nil && doc != nil { return doc, nil } } return nil, ErrDocumentNotFound } // SetDocumentWorkDir 更新文档的工作区路径 func (e *GovernanceEngine) SetDocumentWorkDir(ctx context.Context, docID string, workDir string) error { g := e.acquireDocLock(docID) defer g.Release() doc, exists := e.memDocsGet(docID) if !exists { return ErrDocumentNotFound } doc.WorkDir = workDir doc.UpdatedAt = time.Now() if e.pool != nil { if _, err := e.pool.Exec(ctx, ` UPDATE document_records SET work_dir = $1, updated_at = $2 WHERE doc_id = $3 `, doc.WorkDir, doc.UpdatedAt, docID); err != nil { slog.Error("docgov_workdir_persist_failed", "doc_id", docID, "error", err) } } return nil }