loadDocumentFromDBLocked selected every column except review_snapshot_id, so any document loaded after a restart or cache miss lost its frozen review snapshot and ApproverApprove failed with "approved requires frozen review_snapshot_id". PG regression test seeds a snapshot and asserts a fresh engine carries the id through load.
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
)
|
|
|
|
// GetDocument 获取文档实体 (优先查内存,未命中查 DB)
|
|
func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
if doc, exists := e.memDocs[docID]; exists {
|
|
return doc, nil
|
|
}
|
|
if e.pool != nil {
|
|
if doc, err := e.loadDocumentFromDBLocked(ctx, docID); err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
}
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
|
|
// ListAuditLogs 获取文档审计日志
|
|
func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*AuditLog, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
if len(e.memAudits[docID]) == 0 && e.pool != nil {
|
|
rows, err := e.pool.Query(ctx, `
|
|
SELECT id, doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at
|
|
FROM document_audit_logs
|
|
WHERE doc_id = $1
|
|
ORDER BY created_at ASC
|
|
`, docID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var a AuditLog
|
|
var detJSON []byte
|
|
var role, fromSt, toSt string
|
|
var actID *int64
|
|
if err := rows.Scan(&a.ID, &a.DocID, &a.Action, &actID, &a.ActorName, &role, &fromSt, &toSt, &detJSON, &a.CreatedAt); err == nil {
|
|
if actID != nil {
|
|
a.ActorID = *actID
|
|
}
|
|
a.ActorRole = UserRole(role)
|
|
a.FromState = DocumentState(fromSt)
|
|
a.ToState = DocumentState(toSt)
|
|
if len(detJSON) > 0 {
|
|
_ = json.Unmarshal(detJSON, &a.Details)
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], &a)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("docgov_audit_scan_incomplete", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
logs := e.memAudits[docID]
|
|
res := make([]*AuditLog, len(logs))
|
|
copy(res, logs)
|
|
return res, nil
|
|
}
|
|
|
|
// ListComments 列出文档的所有批注
|
|
func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*DocumentComment, error) {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
if len(e.memComms[docID]) == 0 && e.pool != nil {
|
|
rows, err := e.pool.Query(ctx, `
|
|
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
|
|
FROM document_comments
|
|
WHERE doc_id = $1
|
|
ORDER BY created_at ASC
|
|
`, docID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c DocumentComment
|
|
var uID *int64
|
|
var role, cType string
|
|
if err := rows.Scan(&c.ID, &c.DocID, &uID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt); err == nil {
|
|
if uID != nil {
|
|
c.UserID = *uID
|
|
}
|
|
c.UserRole = UserRole(role)
|
|
c.CommentType = CommentType(cType)
|
|
e.memComms[docID] = append(e.memComms[docID], &c)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("docgov_comments_scan_incomplete", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
comms := e.memComms[docID]
|
|
res := make([]*DocumentComment, len(comms))
|
|
copy(res, comms)
|
|
return res, nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
|
if e.pool == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
row := e.pool.QueryRow(ctx, `
|
|
SELECT id, doc_id, project_id, task_key, title, stage, version,
|
|
current_state, is_locked, is_template_only, content_sha256, work_dir,
|
|
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
|
|
author_claimed, author_claimed_at, author_last_viewed_at, author_last_edited_at,
|
|
reviewer_claimed, reviewer_claimed_at, approver_claimed, approver_claimed_at,
|
|
last_rejected_reason, last_rejected_by_role, last_rejected_by_name, last_rejected_at,
|
|
approved_at, config, created_at, updated_at,
|
|
COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''), COALESCE(lease_work_dir, ''),
|
|
lease_expires_at, lease_heartbeat_at, COALESCE(lease_fencing_token, ''),
|
|
COALESCE(duty_locked, false), COALESCE(duty_policy, ''),
|
|
COALESCE(review_snapshot_id, 0)
|
|
FROM document_records
|
|
WHERE doc_id = $1
|
|
`, docID)
|
|
|
|
var doc DocumentRecord
|
|
var cfgJSON []byte
|
|
var currState string
|
|
var authorID, reviewerID, approverID *int64
|
|
var leaseType, leaseID, leaseWD string
|
|
err := row.Scan(
|
|
&doc.ID, &doc.DocID, &doc.ProjectID, &doc.TaskKey, &doc.Title, &doc.Stage, &doc.Version,
|
|
&currState, &doc.IsLocked, &doc.IsTemplateOnly, &doc.ContentSHA256, &doc.WorkDir,
|
|
&authorID, &doc.AuthorName, &reviewerID, &doc.ReviewerName, &approverID, &doc.ApproverName,
|
|
&doc.AuthorClaimed, &doc.AuthorClaimedAt, &doc.AuthorLastViewedAt, &doc.AuthorLastEditedAt,
|
|
&doc.ReviewerClaimed, &doc.ReviewerClaimedAt, &doc.ApproverClaimed, &doc.ApproverClaimedAt,
|
|
&doc.LastRejectedReason, &doc.LastRejectedByRole, &doc.LastRejectedByName, &doc.LastRejectedAt,
|
|
&doc.ApprovedAt, &cfgJSON, &doc.CreatedAt, &doc.UpdatedAt,
|
|
&leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, &doc.LeaseFencingToken,
|
|
&doc.DutyLocked, &doc.DutyPolicy,
|
|
&doc.ReviewSnapshotID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
doc.CurrentState = DocumentState(currState)
|
|
doc.LeaseHolderType = LeaseHolderType(leaseType)
|
|
doc.LeaseHolderID = leaseID
|
|
doc.LeaseWorkDir = leaseWD
|
|
if authorID != nil {
|
|
doc.AuthorID = *authorID
|
|
}
|
|
if reviewerID != nil {
|
|
doc.ReviewerID = *reviewerID
|
|
}
|
|
if approverID != nil {
|
|
doc.ApproverID = *approverID
|
|
}
|
|
if len(cfgJSON) > 0 {
|
|
_ = json.Unmarshal(cfgJSON, &doc.Config)
|
|
}
|
|
e.memDocs[docID] = &doc
|
|
return &doc, nil
|
|
}
|