Files
Agent-System/internal/docgov/engine_claim.go
T

269 lines
9.4 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.memAudits[docID] = append(e.memAudits[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.memAudits[docID] = append(e.memAudits[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. 已认领:检查是否有待编制人处理的挂起提案
var pendingProp *ChangeProposal
for _, p := range e.memProps {
if p.DocID == docID && p.Status == ProposalPendingAuthorApproval {
pendingProp = p
break
}
}
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.memProps[p.ProposalID] = &p
}
}
return &ModificationCheckResult{
AllowedDirectEdit: false,
RequiresProposal: true,
AuthorClaimed: true,
PendingProposal: pendingProp,
Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权",
}, nil
}