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>
207 lines
6.3 KiB
Go
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
|
|
}
|