Files
Agent-System/internal/docduty/store.go
T
weichenandCursor 09e3581def fix(docduty): serialize heal with the project duty lock
Heal used to resolve and backfill on the pool without the member-write advisory lock, so a stale snapshot of reviewer A could overwrite a committed transfer to B. Lock, resolve, and document updates now share one transaction and the same lock key.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 22:44:40 +00:00

207 lines
6.3 KiB
Go

package docduty
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// dutyDB 覆盖 pool 与 tx,供同一套解析/回填 SQL 复用。
type dutyDB interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
}
// LoadProjectMembers 读取项目在任/历史成员与创建者,供职责解析。
func LoadProjectMembers(ctx context.Context, pool *pgxpool.Pool, projectID int64) ([]Member, Holder, error) {
if pool == nil || projectID <= 0 {
return nil, Holder{}, nil
}
return loadProjectMembers(ctx, pool, projectID)
}
func loadProjectMembers(ctx context.Context, db dutyDB, projectID int64) ([]Member, Holder, error) {
var creator Holder
err := db.QueryRow(ctx, `
SELECT COALESCE(p.creator_user_id, 0), COALESCE(u.name, '')
FROM projects p
LEFT JOIN users u ON u.id = p.creator_user_id
WHERE p.id = $1
`, projectID).Scan(&creator.UserID, &creator.Name)
if err != nil && err != pgx.ErrNoRows {
return nil, Holder{}, fmt.Errorf("docduty: load creator: %w", err)
}
creator.Name = strings.TrimSpace(creator.Name)
rows, err := db.Query(ctx, `
SELECT pm.user_id, COALESCE(u.name, ''), COALESCE(pm.status, ''),
COALESCE(pm.project_role, ''), COALESCE(pm.document_duty, '')
FROM project_members pm
JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = $1
ORDER BY pm.user_id ASC
`, projectID)
if err != nil {
return nil, creator, fmt.Errorf("docduty: load members: %w", err)
}
defer rows.Close()
members := make([]Member, 0)
for rows.Next() {
var m Member
if err := rows.Scan(&m.UserID, &m.Name, &m.Status, &m.ProjectRole, &m.DocumentDuty); err != nil {
return nil, creator, err
}
m.Name = strings.TrimSpace(m.Name)
members = append(members, m)
}
return members, creator, rows.Err()
}
// ResolveProject 从库表解析项目默认文档职责。
func ResolveProject(ctx context.Context, pool *pgxpool.Pool, projectID int64, req RequestDuties) (Binding, error) {
members, creator, err := LoadProjectMembers(ctx, pool, projectID)
if err != nil {
return Binding{}, err
}
return Resolve(members, creator, req), nil
}
func resolveProjectTx(ctx context.Context, tx pgx.Tx, projectID int64, req RequestDuties) (Binding, error) {
members, creator, err := loadProjectMembers(ctx, tx, projectID)
if err != nil {
return Binding{}, err
}
return Resolve(members, creator, req), nil
}
type storedDocDuties struct {
DocID string
Author Holder
Reviewer Holder
Approver Holder
}
// BackfillMissingDocumentDuties 按项目 assigned 职责对齐受控文档;assigned 为空的槽位不臆造、不清空。
func BackfillMissingDocumentDuties(ctx context.Context, pool *pgxpool.Pool, projectID int64, assigned Binding) (int, error) {
if pool == nil || projectID <= 0 {
return 0, nil
}
return backfillMissingDocumentDuties(ctx, pool, projectID, assigned)
}
func backfillMissingDocumentDuties(ctx context.Context, db dutyDB, projectID int64, assigned Binding) (int, error) {
members, creator, err := loadProjectMembers(ctx, db, projectID)
if err != nil {
return 0, err
}
memberIDs := ActiveMemberIDs(members, creator)
rows, err := db.Query(ctx, `
SELECT doc_id,
COALESCE(author_id, 0), COALESCE(author_name, ''),
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
COALESCE(approver_id, 0), COALESCE(approver_name, '')
FROM document_records
WHERE project_id = $1 AND COALESCE(task_key, '') <> ''
`, projectID)
if err != nil {
return 0, fmt.Errorf("docduty: list documents: %w", err)
}
defer rows.Close()
pending := make([]storedDocDuties, 0)
for rows.Next() {
var rec storedDocDuties
if err := rows.Scan(
&rec.DocID,
&rec.Author.UserID, &rec.Author.Name,
&rec.Reviewer.UserID, &rec.Reviewer.Name,
&rec.Approver.UserID, &rec.Approver.Name,
); err != nil {
return 0, err
}
current := Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver}
next, changed := HealBinding(current, assigned, memberIDs)
if !changed {
continue
}
rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver
pending = append(pending, rec)
}
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, rec := range pending {
tag, err := db.Exec(ctx, `
UPDATE document_records
SET author_id = $2, author_name = $3,
reviewer_id = $4, reviewer_name = $5,
approver_id = $6, approver_name = $7,
updated_at = now()
WHERE doc_id = $1 AND project_id = $8
`, rec.DocID, nullIfZero(rec.Author.UserID), rec.Author.Name,
nullIfZero(rec.Reviewer.UserID), rec.Reviewer.Name,
nullIfZero(rec.Approver.UserID), rec.Approver.Name,
projectID)
if err != nil {
return updated, fmt.Errorf("docduty: backfill %s: %w", rec.DocID, err)
}
if tag.RowsAffected() > 0 {
updated++
}
}
return updated, nil
}
func nullIfZero(id int64) any {
if id <= 0 {
return nil
}
return id
}
// HealProjectDocuments 在同一事务内:项目 advisory lock、解析成员职责、读写 document_records。
// 锁键/顺序与成员写事务一致。第二次调用应为 0 行。错误回滚,不得被调用方静默吞掉。
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (Binding, int, error) {
if pool == nil || projectID <= 0 {
return Binding{}, 0, nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return Binding{}, 0, fmt.Errorf("docduty: begin heal: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
return Binding{}, 0, err
}
assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID)
if err != nil {
return Binding{}, 0, err
}
if err := tx.Commit(ctx); err != nil {
return Binding{}, 0, fmt.Errorf("docduty: commit heal: %w", err)
}
return assigned, n, nil
}
func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (Binding, int, error) {
assigned, err := resolveProjectTx(ctx, tx, projectID, RequestDuties{})
if err != nil {
return Binding{}, 0, err
}
n, err := backfillMissingDocumentDuties(ctx, tx, projectID, assigned)
if err != nil {
return Binding{}, 0, err
}
return assigned, n, nil
}