per-doc 锁只串行同一文档,不同 docID 仍裸读写 memDocs 等 map。GetDocument 与另一文档写入撞上时运行时 fatal,代理把全部 API 变成 500。
265 lines
9.3 KiB
Go
265 lines
9.3 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果
|
|
type ModificationCheckResult struct {
|
|
AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改
|
|
RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板
|
|
AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领
|
|
PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// RecordDocumentViewed 记录查看文档行为并触发认领机制
|
|
// 核心规则:
|
|
// 1. 只有当文档处于【非模板状态】(is_template_only = false) 时,编制负责人查看才标记为 AuthorClaimed = true;模板状态查看不算认领!
|
|
// 2. 审核人查看且处于审核阶段,标记 ReviewerClaimed = true
|
|
// 3. 批准人查看且处于批准阶段,标记 ApproverClaimed = true
|
|
func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, err := e.ensureDocLockedWithDB(ctx, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
var auditAction string
|
|
|
|
switch actorRole {
|
|
case RoleAuthor:
|
|
doc.AuthorLastViewedAt = &now
|
|
auditAction = "author_view"
|
|
// 关键认领判定:只限非模板状态
|
|
if !doc.IsTemplateOnly && !doc.AuthorClaimed {
|
|
doc.AuthorClaimed = true
|
|
doc.AuthorClaimedAt = &now
|
|
auditAction = "author_claim_by_view"
|
|
}
|
|
case RoleReviewer:
|
|
auditAction = "reviewer_view"
|
|
if !doc.IsTemplateOnly && !doc.ReviewerClaimed {
|
|
doc.ReviewerClaimed = true
|
|
doc.ReviewerClaimedAt = &now
|
|
auditAction = "reviewer_claim_by_view"
|
|
}
|
|
case RoleApprover:
|
|
auditAction = "approver_view"
|
|
if !doc.IsTemplateOnly && !doc.ApproverClaimed {
|
|
doc.ApproverClaimed = true
|
|
doc.ApproverClaimedAt = &now
|
|
auditAction = "approver_claim_by_view"
|
|
}
|
|
default:
|
|
auditAction = "view"
|
|
}
|
|
|
|
doc.UpdatedAt = now
|
|
|
|
// 记录审计日志
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: auditAction,
|
|
ActorID: actorID,
|
|
ActorName: actorName,
|
|
ActorRole: actorRole,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"is_template_only": doc.IsTemplateOnly,
|
|
"author_claimed": doc.AuthorClaimed,
|
|
"timestamp": now,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAuditsAppend(docID, audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET author_claimed = $1, author_claimed_at = $2, author_last_viewed_at = $3,
|
|
reviewer_claimed = $4, reviewer_claimed_at = $5,
|
|
approver_claimed = $6, approver_claimed_at = $7,
|
|
updated_at = $8
|
|
WHERE doc_id = $9
|
|
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastViewedAt,
|
|
doc.ReviewerClaimed, doc.ReviewerClaimedAt,
|
|
doc.ApproverClaimed, doc.ApproverClaimedAt,
|
|
doc.UpdatedAt, docID); err != nil {
|
|
slog.Error("docgov_viewed_persist_failed", "doc_id", docID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// RecordDocumentEdited 编制人编辑文档 (标记认领与编辑时间)
|
|
func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, err := e.ensureDocLockedWithDB(ctx, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole, false); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
if doc.HasActiveLease(now) {
|
|
holderID := fmt.Sprintf("%d", actorID)
|
|
sameHuman := doc.LeaseHolderType == LeaseHolderHuman && (doc.LeaseHolderID == holderID || (actorID <= 0 && doc.LeaseHolderID == "human_anon"))
|
|
if !sameHuman {
|
|
return nil, &DocumentBusyError{
|
|
DocID: doc.DocID,
|
|
HolderType: doc.LeaseHolderType,
|
|
HolderID: doc.LeaseHolderID,
|
|
WorkDir: doc.LeaseWorkDir,
|
|
ExpiresAt: *doc.LeaseExpiresAt,
|
|
Reason: fmt.Sprintf("文档正在由 %s 编辑/生成中,禁止人工编辑", doc.LeaseHolderType),
|
|
}
|
|
}
|
|
}
|
|
|
|
doc.AuthorLastEditedAt = &now
|
|
doc.AuthorClaimed = true
|
|
if doc.AuthorClaimedAt == nil {
|
|
doc.AuthorClaimedAt = &now
|
|
}
|
|
doc.UpdatedAt = now
|
|
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: "author_edit",
|
|
ActorID: actorID,
|
|
ActorName: actorName,
|
|
ActorRole: actorRole,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{"timestamp": now},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAuditsAppend(docID, audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET author_claimed = $1, author_claimed_at = $2, author_last_edited_at = $3, updated_at = $4
|
|
WHERE doc_id = $5
|
|
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID); err != nil {
|
|
slog.Error("docgov_edited_persist_failed", "doc_id", docID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// CheckModificationPermission 阶段发起修改编制状态文档前,检查认领与权限
|
|
// 核心逻辑:
|
|
// 1. 若文档未认领 (author_claimed == false),系统不用告知负责人,直接放行智能体小队编辑 (AllowedDirectEdit = true)
|
|
// 2. 若文档已认领 (author_claimed == true),系统拦截直接编辑,必须通知编制负责人并生成变更提案 (RequiresProposal = true)
|
|
func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docID string, actorRole UserRole) (*ModificationCheckResult, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, err := e.ensureDocLockedWithDB(ctx, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 1. 检查锁定状态
|
|
if doc.IsLocked {
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: doc.AuthorClaimed,
|
|
Reason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止任何修改", doc.CurrentState),
|
|
}, ErrDocumentLocked
|
|
}
|
|
|
|
// 1b. 运行时编辑租约占用(正交于 IsLocked)
|
|
now := time.Now()
|
|
if doc.HasActiveLease(now) {
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: doc.AuthorClaimed,
|
|
Reason: fmt.Sprintf("文档正在被 %s(%s) 编辑中", doc.LeaseHolderType, doc.LeaseHolderID),
|
|
}, &DocumentBusyError{
|
|
DocID: doc.DocID,
|
|
HolderType: doc.LeaseHolderType,
|
|
HolderID: doc.LeaseHolderID,
|
|
WorkDir: doc.LeaseWorkDir,
|
|
ExpiresAt: *doc.LeaseExpiresAt,
|
|
Reason: fmt.Sprintf("文档正在被 %s(%s) 编辑中,禁止并行修改", doc.LeaseHolderType, doc.LeaseHolderID),
|
|
}
|
|
}
|
|
|
|
// 2. 检查认领状态
|
|
if !doc.AuthorClaimed {
|
|
// 未认领:智能体小队可静默直接修改
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: true,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: false,
|
|
Reason: "文档尚未被编制负责人认领(未查看正式稿),允许智能体小队直接自动编辑",
|
|
}, nil
|
|
}
|
|
|
|
// 3. 已认领:检查是否有待编制人处理的挂起提案
|
|
pendingProp := e.memPropsFind(func(p *ChangeProposal) bool {
|
|
return p.DocID == docID && p.Status == ProposalPendingAuthorApproval
|
|
})
|
|
if pendingProp == nil && e.pool != nil {
|
|
var p ChangeProposal
|
|
var stat string
|
|
err := e.pool.QueryRow(ctx, `
|
|
SELECT id, proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, author_decision_reason, decided_at, created_at
|
|
FROM document_change_proposals
|
|
WHERE doc_id = $1 AND status = 'pending_author_approval'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, docID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
|
|
if err == nil {
|
|
p.Status = ProposalStatus(stat)
|
|
pendingProp = &p
|
|
e.memPropsPut(p.ProposalID, &p)
|
|
}
|
|
}
|
|
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: true,
|
|
AuthorClaimed: true,
|
|
PendingProposal: pendingProp,
|
|
Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权",
|
|
}, nil
|
|
}
|