Files
WeiCHEN 9c7a5be209 feat(arch): clear G1 U3/U4 sql_write and path exceptions
Move document_records duty-slot writes into docgov, project ledger
heads through docgov/freedoc ports, and workspace deliverable I/O
through DeliverableStore so arch-check baseline drops to sql_write:1
path:13.
2026-09-22 21:40:12 +08:00

136 lines
4.2 KiB
Go

package docduty
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// MemberDutyChange 成员职责/在任状态变更,供显式撤销文档槽位。
type MemberDutyChange struct {
UserID int64
PreviousDuty string
NextDuty string
Exited bool
PriorHolderIDs map[string]int64
}
// SlotRevocation 从受控文档上撤销某用户的审核/批准槽位;永不针对 author。
type SlotRevocation struct {
UserID int64
Duty string
}
// PriorDutyHolders 更新前在任成员的职责持有者。
func PriorDutyHolders(members []Member) map[string]int64 {
out := make(map[string]int64, 3)
for _, m := range members {
if !isActiveMember(m) {
continue
}
switch m.DocumentDuty {
case DutyAuthor, DutyReviewer, DutyApprover:
out[m.DocumentDuty] = m.UserID
}
}
return out
}
// PlanSlotRevocations 按更新前 duty 显式计算要撤销的审核/批准槽位。
// reviewer/approver → viewer/其他职责、职责转移、退出成员都会产生撤销;author 永不进入结果。
func PlanSlotRevocations(change MemberDutyChange) []SlotRevocation {
seen := make(map[string]struct{}, 4)
out := make([]SlotRevocation, 0, 2)
add := func(userID int64, duty string) {
if userID <= 0 || (duty != DutyReviewer && duty != DutyApprover) {
return
}
key := fmt.Sprintf("%d:%s", userID, duty)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, SlotRevocation{UserID: userID, Duty: duty})
}
if change.Exited || change.PreviousDuty != change.NextDuty {
add(change.UserID, change.PreviousDuty)
}
if change.NextDuty == DutyReviewer || change.NextDuty == DutyApprover {
if prior := change.PriorHolderIDs[change.NextDuty]; prior > 0 && prior != change.UserID {
add(prior, change.NextDuty)
}
}
return out
}
// LoadMemberDutyTx 在调用方事务内读取该成员当前 document_duty;无行则空串。
func LoadMemberDutyTx(ctx context.Context, tx pgx.Tx, projectID, userID int64) (string, error) {
var duty string
err := tx.QueryRow(ctx, `
SELECT COALESCE(document_duty, '')
FROM project_members
WHERE project_id = $1 AND user_id = $2
`, projectID, userID).Scan(&duty)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("docduty: load member duty: %w", err)
}
return duty, nil
}
// LoadActiveSlotHoldersTx 在调用方事务内读取在任 reviewer/approver(不含 author)。
func LoadActiveSlotHoldersTx(ctx context.Context, tx pgx.Tx, projectID int64) (map[string]int64, error) {
rows, err := tx.Query(ctx, `
SELECT user_id, document_duty
FROM project_members
WHERE project_id = $1 AND status = $2 AND document_duty IN ($3, $4)
`, projectID, StatusActive, DutyReviewer, DutyApprover)
if err != nil {
return nil, fmt.Errorf("docduty: load slot holders: %w", err)
}
defer rows.Close()
out := make(map[string]int64, 2)
for rows.Next() {
var userID int64
var duty string
if err := rows.Scan(&userID, &duty); err != nil {
return nil, fmt.Errorf("docduty: scan slot holder: %w", err)
}
out[duty] = userID
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("docduty: slot holders: %w", err)
}
return out, nil
}
// projectDutyLockSQL 成员写与 HealProjectDocuments 共用同一键:pg_advisory_xact_lock(project_id)。
// 必须先于成员/文档读写;禁止改成两段 int 锁或其它键。
const projectDutyLockSQL = `SELECT pg_advisory_xact_lock($1)`
// projectDutyLockHook 仅测试使用:在申请锁之前同步,便于证明 heal 会阻塞在成员写事务之后。
var projectDutyLockHook func()
// SetProjectDutyLockHookForTest installs a pre-lock hook for PG concurrency tests.
func SetProjectDutyLockHookForTest(fn func()) {
projectDutyLockHook = fn
}
// AcquireProjectDutyLock 获取项目职责互斥锁;调用方须已 Begin,锁随事务结束释放。
func AcquireProjectDutyLock(ctx context.Context, tx pgx.Tx, projectID int64) error {
if tx == nil || projectID <= 0 {
return fmt.Errorf("docduty: invalid project lock")
}
if hook := projectDutyLockHook; hook != nil {
hook()
}
if _, err := tx.Exec(ctx, projectDutyLockSQL, projectID); err != nil {
return fmt.Errorf("docduty: lock project %d: %w", projectID, err)
}
return nil
}