feat(docduty): resolve project reviewer onto controlled docs

Stop inventing reviewer_id=2. Use project document_duty as the authority, heal missing or non-member placeholders, and keep write paths rejected for reviewer and outsiders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 21:57:44 +00:00
co-authored by Cursor
parent 6cd3991bad
commit 6a68e04f1f
11 changed files with 889 additions and 60 deletions
+83 -37
View File
@@ -8,12 +8,14 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/docduty"
)
var (
ErrForbidden = errors.New("authz: forbidden")
ErrProjectNotFound = errors.New("authz: project not found")
ErrUnauthenticated = errors.New("authz: unauthenticated")
ErrForbidden = errors.New("authz: forbidden")
ErrProjectNotFound = errors.New("authz: project not found")
ErrUnauthenticated = errors.New("authz: unauthenticated")
)
const (
@@ -32,14 +34,21 @@ var allowedProjectRoles = map[string]struct{}{
// ProjectMemberRecord 项目成员 API 视图。
type ProjectMemberRecord struct {
ID int64 `json:"id,omitempty"`
ProjectID int64 `json:"project_id,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
ProjectRole string `json:"project_role"`
Status string `json:"status"`
JoinedAt string `json:"joined_at,omitempty"`
ID int64 `json:"id,omitempty"`
ProjectID int64 `json:"project_id,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
ProjectRole string `json:"project_role"`
DocumentDuty string `json:"document_duty,omitempty"`
Status string `json:"status"`
JoinedAt string `json:"joined_at,omitempty"`
}
// MemberUpsert 项目访问角色 + 可选文档职责。
type MemberUpsert struct {
ProjectRole string
DocumentDuty *string
}
// Authorizer 项目级对象授权。
@@ -55,20 +64,13 @@ func (a *Authorizer) enabled() bool {
return a != nil && a.pool != nil
}
// AddOwnerOnCreate 项目创建时写入 owner 成员关系。
// AddOwnerOnCreate 项目创建时写入 owner 成员关系,并默认编制职责。
func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error {
if !a.enabled() || projectID <= 0 || userID <= 0 {
return nil
}
_, err := a.pool.Exec(ctx, `
INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (project_id, user_id) DO UPDATE SET
project_role = EXCLUDED.project_role,
status = EXCLUDED.status,
updated_at = now()
`, projectID, userID, RoleOwner, StatusActive)
return err
duty := docduty.DutyAuthor
return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{
ProjectRole: RoleOwner,
DocumentDuty: &duty,
})
}
// IsProjectMember 判断用户是否为项目 active 成员或创建人。
@@ -198,7 +200,7 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([
SELECT pm.id, pm.project_id, pm.user_id,
COALESCE(NULLIF(u.username, ''), u.account_id, '') AS username,
COALESCE(u.name, '') AS name,
pm.project_role, pm.status, pm.joined_at
pm.project_role, COALESCE(pm.document_duty, ''), pm.status, pm.joined_at
FROM project_members pm
JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = $1
@@ -215,7 +217,7 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([
if err := rows.Scan(
&rec.ID, &rec.ProjectID, &rec.UserID,
&rec.Username, &rec.Name,
&rec.ProjectRole, &rec.Status, &joined,
&rec.ProjectRole, &rec.DocumentDuty, &rec.Status, &joined,
); err != nil {
return nil, err
}
@@ -225,26 +227,70 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([
return out, rows.Err()
}
// UpsertProjectMember 新增或更新成员角色(禁止 docgov 专用角色名)。
// UpsertProjectMember 新增或更新成员访问角色(禁止把 author/reviewer/approver 写入 project_role)。
func (a *Authorizer) UpsertProjectMember(ctx context.Context, projectID, userID int64, projectRole string) error {
return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{ProjectRole: projectRole})
}
// UpsertProjectMemberSpec 写入访问角色,并可选独占 document_duty。
func (a *Authorizer) UpsertProjectMemberSpec(ctx context.Context, projectID, userID int64, spec MemberUpsert) error {
if projectID <= 0 || userID <= 0 {
return fmt.Errorf("authz: invalid project or user id")
}
if err := validateProjectRole(projectRole); err != nil {
if err := validateProjectRole(spec.ProjectRole); err != nil {
return err
}
var duty string
var err error
if spec.DocumentDuty != nil {
duty, err = docduty.NormalizeDocumentDuty(*spec.DocumentDuty)
if err != nil {
return err
}
}
if !a.enabled() {
return nil
}
_, err := a.pool.Exec(ctx, `
INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (project_id, user_id) DO UPDATE SET
project_role = EXCLUDED.project_role,
status = EXCLUDED.status,
updated_at = now()
`, projectID, userID, projectRole, StatusActive)
return err
tx, err := a.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if spec.DocumentDuty != nil && duty != docduty.DutyNone {
if _, err := tx.Exec(ctx, `
UPDATE project_members
SET document_duty = '', updated_at = now()
WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4
`, projectID, duty, userID, StatusActive); err != nil {
return err
}
}
if spec.DocumentDuty != nil {
_, err = tx.Exec(ctx, `
INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at)
VALUES ($1, $2, $3, $4, $5, now(), now())
ON CONFLICT (project_id, user_id) DO UPDATE SET
project_role = EXCLUDED.project_role,
document_duty = EXCLUDED.document_duty,
status = EXCLUDED.status,
updated_at = now()
`, projectID, userID, spec.ProjectRole, duty, StatusActive)
} else {
_, err = tx.Exec(ctx, `
INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (project_id, user_id) DO UPDATE SET
project_role = EXCLUDED.project_role,
status = EXCLUDED.status,
updated_at = now()
`, projectID, userID, spec.ProjectRole, StatusActive)
}
if err != nil {
return err
}
return tx.Commit(ctx)
}
// SetProjectMemberStatus 更新成员状态(如 exited)。
+7
View File
@@ -34,4 +34,11 @@ func TestUpsertProjectMember_RejectsDocRolesWithoutDB(t *testing.T) {
if err := a.UpsertProjectMember(context.Background(), 1, 2, authz.RoleOwner); err != nil {
t.Fatalf("owner should be allowed: %v", err)
}
bad := "viewer"
if err := a.UpsertProjectMemberSpec(context.Background(), 1, 2, authz.MemberUpsert{
ProjectRole: authz.RoleMember,
DocumentDuty: &bad,
}); err == nil {
t.Fatal("document_duty=viewer must be rejected")
}
}
@@ -0,0 +1,40 @@
package bizdev
import (
"context"
"testing"
"chat-agent-system/internal/docduty"
)
func TestResolveTaskDocumentDuties_UsesCreatorNotHardcodedReviewer(t *testing.T) {
req := TaskRunRequest{
UserID: 170, ProjectID: 6145,
AuthorName: "编制人A",
}
got := resolveTaskDocumentDuties(context.Background(), nil, req)
if got.Author.UserID != 170 {
t.Fatalf("author want 170 got %+v", got.Author)
}
if got.Reviewer.UserID == 2 || got.Approver.UserID == 3 {
t.Fatalf("must not invent hardcoded 2/3: %+v", got)
}
if got.Reviewer.UserID != 0 {
t.Fatalf("no project members → reviewer empty, got %+v", got.Reviewer)
}
}
func TestResolveTaskDocumentDuties_IgnoresNonMemberRequestReviewer(t *testing.T) {
req := TaskRunRequest{
UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人",
}
got := resolveTaskDocumentDuties(context.Background(), nil, req)
if got.Reviewer.UserID != 0 {
t.Fatalf("request reviewer 2 is not a member: %+v", got.Reviewer)
}
}
func TestBindingFromDocRoundTrip(t *testing.T) {
// compile-time guard that helper stays aligned with Resolve slots
_ = docduty.Binding{}
}
+56 -17
View File
@@ -16,6 +16,7 @@ import (
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
@@ -453,28 +454,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
}
}
authorID := req.AuthorID
if authorID == 0 {
authorID = req.UserID
}
authorName := req.AuthorName
duties := resolveTaskDocumentDuties(ctx, s.pool, req)
authorID, authorName := duties.Author.UserID, duties.Author.Name
reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name
approverID, approverName := duties.Approver.UserID, duties.Approver.Name
if authorName == "" {
authorName = "编制工程师"
}
reviewerID := req.ReviewerID
if reviewerID == 0 {
reviewerID = 2
}
reviewerName := req.ReviewerName
if reviewerName == "" {
if reviewerName == "" && reviewerID > 0 {
reviewerName = "审核负责人"
}
approverID := req.ApproverID
if approverID == 0 {
approverID = 3
}
approverName := req.ApproverName
if approverName == "" {
if approverName == "" && approverID > 0 {
approverName = "批准负责人"
}
@@ -489,6 +479,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
"V0.5_feasibility", req.WorkDir,
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
)
if s.pool != nil && req.ProjectID > 0 {
if assigned, _, healErr := docduty.HealProjectDocuments(ctx, s.pool, req.ProjectID); healErr == nil && docRec != nil {
if members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID); loadErr == nil {
next, _ := docduty.HealBinding(bindingFromDoc(docRec), assigned, docduty.ActiveMemberIDs(members, creator))
writeBindingToDoc(docRec, next)
}
}
}
if req.ForceDirectEdit {
if docRec != nil && docRec.IsLocked {
@@ -1297,3 +1295,44 @@ func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad.
func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) {
return s.squad.ResumeOrModifyWorkspace(ctx, req)
}
func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest) docduty.Binding {
reqDuties := docduty.RequestDuties{
AuthorID: req.AuthorID, AuthorName: req.AuthorName,
ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName,
ApproverID: req.ApproverID, ApproverName: req.ApproverName,
}
if reqDuties.AuthorID == 0 {
reqDuties.AuthorID = req.UserID
}
if pool != nil && req.ProjectID > 0 {
if bound, err := docduty.ResolveProject(ctx, pool, req.ProjectID, reqDuties); err == nil {
if bound.Author.UserID == 0 && req.UserID > 0 {
bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
}
return bound
}
}
creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
return docduty.Resolve(nil, creator, reqDuties)
}
func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding {
if doc == nil {
return docduty.Binding{}
}
return docduty.Binding{
Author: docduty.Holder{UserID: doc.AuthorID, Name: doc.AuthorName},
Reviewer: docduty.Holder{UserID: doc.ReviewerID, Name: doc.ReviewerName},
Approver: docduty.Holder{UserID: doc.ApproverID, Name: doc.ApproverName},
}
}
func writeBindingToDoc(doc *docgov.DocumentRecord, bound docduty.Binding) {
if doc == nil {
return
}
doc.AuthorID, doc.AuthorName = bound.Author.UserID, bound.Author.Name
doc.ReviewerID, doc.ReviewerName = bound.Reviewer.UserID, bound.Reviewer.Name
doc.ApproverID, doc.ApproverName = bound.Approver.UserID, bound.Approver.Name
}
+174
View File
@@ -0,0 +1,174 @@
package docduty
import (
"fmt"
"strings"
)
const (
DutyNone = ""
DutyAuthor = "author"
DutyReviewer = "reviewer"
DutyApprover = "approver"
StatusActive = "active"
)
// Holder 某一文档职责的用户。
type Holder struct {
UserID int64
Name string
}
// Binding 一份受控文档应采用的编制/审核/批准职责。
type Binding struct {
Author Holder
Reviewer Holder
Approver Holder
}
// Member 项目成员快照(访问角色与文档职责分离)。
type Member struct {
UserID int64
Name string
Status string
ProjectRole string
DocumentDuty string
}
// RequestDuties 任务请求里客户端声称的职责;仅当该用户是在任成员时才可采用。
type RequestDuties struct {
AuthorID int64
AuthorName string
ReviewerID int64
ReviewerName string
ApproverID int64
ApproverName string
}
// NormalizeDocumentDuty 校验 document_duty。空串表示无文档职责。
func NormalizeDocumentDuty(raw string) (string, error) {
duty := strings.TrimSpace(strings.ToLower(raw))
switch duty {
case DutyNone, DutyAuthor, DutyReviewer, DutyApprover:
return duty, nil
default:
return "", fmt.Errorf("docduty: invalid document_duty %q", raw)
}
}
// ActiveMemberIDs 在任成员(含创建者)。
func ActiveMemberIDs(members []Member, creator Holder) map[int64]struct{} {
ids := make(map[int64]struct{}, len(members)+1)
if creator.UserID > 0 {
ids[creator.UserID] = struct{}{}
}
for _, m := range members {
if m.UserID > 0 && (m.Status == "" || m.Status == StatusActive) {
ids[m.UserID] = struct{}{}
}
}
return ids
}
func isActiveMember(m Member) bool {
return m.UserID > 0 && (m.Status == "" || m.Status == StatusActive)
}
func dutyHolder(members []Member, duty string) Holder {
var found Holder
for _, m := range members {
if !isActiveMember(m) || m.DocumentDuty != duty {
continue
}
found = Holder{UserID: m.UserID, Name: strings.TrimSpace(m.Name)}
}
return found
}
func pickMember(reqID int64, reqName string, memberIDs map[int64]struct{}, members []Member) Holder {
if reqID <= 0 {
return Holder{}
}
if _, ok := memberIDs[reqID]; !ok {
return Holder{}
}
name := strings.TrimSpace(reqName)
for _, m := range members {
if m.UserID == reqID && isActiveMember(m) && strings.TrimSpace(m.Name) != "" {
name = strings.TrimSpace(m.Name)
break
}
}
return Holder{UserID: reqID, Name: name}
}
// Resolve 以项目 document_duty 为权威。禁止回落到硬编码 2/3。
// author:指定编制人 → 创建者 → 请求 id(须为成员)。
// reviewer/approver:指定职责 → 请求 id(须为成员)→ 空。
func Resolve(members []Member, creator Holder, req RequestDuties) Binding {
memberIDs := ActiveMemberIDs(members, creator)
out := Binding{}
if h := dutyHolder(members, DutyAuthor); h.UserID > 0 {
out.Author = h
} else if creator.UserID > 0 {
out.Author = creator
} else {
out.Author = pickMember(req.AuthorID, req.AuthorName, memberIDs, members)
}
if h := dutyHolder(members, DutyReviewer); h.UserID > 0 {
out.Reviewer = h
} else {
out.Reviewer = pickMember(req.ReviewerID, req.ReviewerName, memberIDs, members)
}
if h := dutyHolder(members, DutyApprover); h.UserID > 0 {
out.Approver = h
} else {
out.Approver = pickMember(req.ApproverID, req.ApproverName, memberIDs, members)
}
return out
}
// DutyMissing 当前职责缺失或指向非成员(含历史硬编码占位 id)。
func DutyMissing(currentID int64, memberIDs map[int64]struct{}) bool {
if currentID <= 0 {
return true
}
_, ok := memberIDs[currentID]
return !ok
}
func healSlot(current, assigned Holder, memberIDs map[int64]struct{}) (Holder, bool) {
if !DutyMissing(current.UserID, memberIDs) {
return current, false
}
if assigned.UserID <= 0 {
return current, false
}
if current.UserID == assigned.UserID && current.Name == assigned.Name {
return current, false
}
return assigned, true
}
// HealBinding 仅回填缺失/非成员槽位;已是在任成员的职责不覆盖,避免作者/批准人串位。
func HealBinding(current, assigned Binding, memberIDs map[int64]struct{}) (Binding, bool) {
out := current
changed := false
if next, ok := healSlot(current.Author, assigned.Author, memberIDs); ok {
out.Author = next
changed = true
}
if next, ok := healSlot(current.Reviewer, assigned.Reviewer, memberIDs); ok {
out.Reviewer = next
changed = true
}
if next, ok := healSlot(current.Approver, assigned.Approver, memberIDs); ok {
out.Approver = next
changed = true
}
return out, changed
}
+161
View File
@@ -0,0 +1,161 @@
package docduty
import (
"sync"
"testing"
)
func sampleMembers() ([]Member, Holder) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor},
{UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyReviewer},
{UserID: 3, Name: "李主管", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyNone},
{UserID: 4, Name: "王总监", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyApprover},
}
return members, creator
}
func TestResolve_AAuthorBReviewerCNone(t *testing.T) {
members, creator := sampleMembers()
got := Resolve(members, creator, RequestDuties{
AuthorID: 2, ReviewerID: 2, ApproverID: 3,
AuthorName: "编制工程师", ReviewerName: "审核负责人", ApproverName: "批准负责人",
})
if got.Author.UserID != 170 || got.Author.Name != "编制人A" {
t.Fatalf("author: %+v", got.Author)
}
if got.Reviewer.UserID != 171 || got.Reviewer.Name != "审核人B" {
t.Fatalf("reviewer: %+v", got.Reviewer)
}
if got.Approver.UserID != 4 || got.Approver.Name != "王总监" {
t.Fatalf("approver: %+v", got.Approver)
}
if got.Author.UserID == got.Reviewer.UserID || got.Reviewer.UserID == got.Approver.UserID {
t.Fatalf("duties swapped: %+v", got)
}
if got.Reviewer.UserID == 99 || got.Author.UserID == 99 {
t.Fatal("non-member C must not receive a duty")
}
}
func TestResolve_NeverDefaultsToHardcodedTwoOrThree(t *testing.T) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor},
}
got := Resolve(members, creator, RequestDuties{})
if got.Reviewer.UserID != 0 || got.Approver.UserID != 0 {
t.Fatalf("expected empty reviewer/approver, got %+v", got)
}
if got.Reviewer.UserID == 2 || got.Approver.UserID == 3 {
t.Fatal("hardcoded 2/3 must not be invented")
}
}
func TestResolve_RequestIDsOnlyIfMembers(t *testing.T) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner"},
{UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member"},
}
got := Resolve(members, creator, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
if got.Reviewer.UserID != 0 {
t.Fatalf("non-member request reviewer 2 must be ignored, got %+v", got.Reviewer)
}
got = Resolve(members, creator, RequestDuties{ReviewerID: 171, ReviewerName: "审核人B"})
if got.Reviewer.UserID != 171 {
t.Fatalf("member request reviewer must apply: %+v", got.Reviewer)
}
}
func TestHealBinding_PlaceholderNonMemberBackfillIdempotent(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
current := Binding{
Author: Holder{UserID: 170, Name: "编制工程师"},
Reviewer: Holder{UserID: 2, Name: "审核负责人"},
Approver: Holder{UserID: 3, Name: "批准负责人"},
}
// 3 是在任成员:批准槽位不得被改写成别人。
first, changed := HealBinding(current, assigned, memberIDs)
if !changed {
t.Fatal("placeholder reviewer 2 must be healed")
}
if first.Reviewer.UserID != 171 {
t.Fatalf("reviewer want 171 got %+v", first.Reviewer)
}
if first.Author.UserID != 170 {
t.Fatalf("author must stay A: %+v", first.Author)
}
if first.Approver.UserID != 3 {
t.Fatalf("member approver 3 must not be swapped: %+v", first.Approver)
}
second, changedAgain := HealBinding(first, assigned, memberIDs)
if changedAgain {
t.Fatalf("heal must be idempotent, got %+v", second)
}
if second != first {
t.Fatalf("second pass changed binding: %+v vs %+v", first, second)
}
}
func TestHealBinding_ZeroDutyBackfill(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
got, changed := HealBinding(Binding{}, assigned, memberIDs)
if !changed || got.Reviewer.UserID != 171 || got.Author.UserID != 170 {
t.Fatalf("zero duties: changed=%v got=%+v", changed, got)
}
}
func TestResolveAndHeal_SecondDocumentInherits(t *testing.T) {
members, creator := sampleMembers()
first := Resolve(members, creator, RequestDuties{})
second := Resolve(members, creator, RequestDuties{})
if first.Reviewer.UserID != 171 || second.Reviewer.UserID != 171 {
t.Fatalf("second document must inherit reviewer B: first=%+v second=%+v", first, second)
}
if first.Author.UserID != second.Author.UserID || first.Approver.UserID != second.Approver.UserID {
t.Fatalf("second document duties diverged: %+v vs %+v", first, second)
}
}
func TestResolve_ConcurrentIdempotent(t *testing.T) {
members, creator := sampleMembers()
var wg sync.WaitGroup
errCh := make(chan string, 32)
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got := Resolve(members, creator, RequestDuties{ReviewerID: 2})
if got.Reviewer.UserID != 171 || got.Author.UserID != 170 {
errCh <- "resolve mismatch"
}
memberIDs := ActiveMemberIDs(members, creator)
healed, _ := HealBinding(Binding{Reviewer: Holder{UserID: 2, Name: "审核负责人"}}, got, memberIDs)
if healed.Reviewer.UserID != 171 {
errCh <- "heal mismatch"
}
}()
}
wg.Wait()
close(errCh)
for msg := range errCh {
t.Fatal(msg)
}
}
func TestNormalizeDocumentDuty(t *testing.T) {
if _, err := NormalizeDocumentDuty("reviewer"); err != nil {
t.Fatal(err)
}
if _, err := NormalizeDocumentDuty("viewer"); err == nil {
t.Fatal("viewer is not a document duty")
}
}
+155
View File
@@ -0,0 +1,155 @@
package docduty
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// LoadProjectMembers 读取项目在任/历史成员与创建者,供职责解析。
func LoadProjectMembers(ctx context.Context, pool *pgxpool.Pool, projectID int64) ([]Member, Holder, error) {
if pool == nil || projectID <= 0 {
return nil, Holder{}, nil
}
var creator Holder
err := pool.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 := pool.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
}
type storedDocDuties struct {
DocID string
Author Holder
Reviewer Holder
Approver Holder
}
// BackfillMissingDocumentDuties 对项目内受控文档回填缺失/非成员职责;已是成员的槽位不覆盖。
func BackfillMissingDocumentDuties(ctx context.Context, pool *pgxpool.Pool, projectID int64, assigned Binding) (int, error) {
if pool == nil || projectID <= 0 {
return 0, nil
}
members, creator, err := LoadProjectMembers(ctx, pool, projectID)
if err != nil {
return 0, err
}
memberIDs := ActiveMemberIDs(members, creator)
rows, err := pool.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 := pool.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 解析项目职责并回填缺失文档槽位;第二次调用应为 0 行。
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (Binding, int, error) {
assigned, err := ResolveProject(ctx, pool, projectID, RequestDuties{})
if err != nil {
return Binding{}, 0, err
}
n, err := BackfillMissingDocumentDuties(ctx, pool, projectID, assigned)
return assigned, n, err
}
+145
View File
@@ -0,0 +1,145 @@
package docduty
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/db"
)
func TestProjectReviewerDutySync_PG(t *testing.T) {
if testing.Short() {
t.Skip("skipping PostgreSQL duty sync integration in -short")
}
dsn := db.SkipIfNoTestDatabaseURL(t)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
t.Skipf("postgres ping failed: %v", err)
}
if err := db.RunMigrations(ctx, pool, db.ResolveMigrationsDir()); err != nil {
t.Skipf("migration failed: %v", err)
}
suffix := time.Now().UnixNano()
authorID := insertDutyUser(t, ctx, pool, suffix, "author")
reviewerID := insertDutyUser(t, ctx, pool, suffix, "reviewer")
outsiderID := insertDutyUser(t, ctx, pool, suffix, "none")
placeholderID := insertDutyUser(t, ctx, pool, suffix, "placeholder")
var projectID int64
if err := pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id)
VALUES ($1, $2, 'active', $3)
RETURNING id
`, fmt.Sprintf("duty-sync-%d", suffix), "reviewer duty sync", authorID).Scan(&projectID); err != nil {
t.Fatalf("insert project: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3,$4)`, authorID, reviewerID, outsiderID, placeholderID)
})
if _, err := pool.Exec(ctx, `
INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at)
VALUES ($1, $2, 'owner', 'author', 'active', now(), now()),
($1, $3, 'member', 'reviewer', 'active', now(), now())
`, projectID, authorID, reviewerID); err != nil {
t.Fatalf("insert members: %v", err)
}
bound, err := ResolveProject(ctx, pool, projectID, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
if err != nil {
t.Fatal(err)
}
if bound.Author.UserID != authorID || bound.Reviewer.UserID != reviewerID {
t.Fatalf("resolve A/B: %+v", bound)
}
if bound.Reviewer.UserID == outsiderID || bound.Author.UserID == outsiderID {
t.Fatal("C none must not receive duty")
}
intakeID := fmt.Sprintf("doc_proj%d_intake", projectID)
secondID := fmt.Sprintf("doc_proj%d_prelim_design", projectID)
insertDutyDoc(t, ctx, pool, projectID, intakeID, "intake", authorID, placeholderID, 0)
insertDutyDoc(t, ctx, pool, projectID, secondID, "prelim_design", authorID, placeholderID, 0)
assigned, n, err := HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
if n == 0 {
t.Fatal("expected non-member placeholder reviewer to be backfilled")
}
if assigned.Reviewer.UserID != reviewerID {
t.Fatalf("assigned reviewer=%d", assigned.Reviewer.UserID)
}
assertDocReviewer(t, ctx, pool, intakeID, reviewerID)
assertDocReviewer(t, ctx, pool, secondID, reviewerID)
_, n2, err := HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
if n2 != 0 {
t.Fatalf("heal must be idempotent, second pass updated %d rows", n2)
}
assertDocReviewer(t, ctx, pool, secondID, reviewerID)
}
func insertDutyUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix int64, kind string) int64 {
t.Helper()
var id int64
account := fmt.Sprintf("duty_%s_%d", kind, suffix)
err := pool.QueryRow(ctx, `
INSERT INTO users (account_id, name, role)
VALUES ($1, $2, 'engineer')
RETURNING id
`, account, "用户"+kind).Scan(&id)
if err != nil {
t.Fatalf("insert user %s: %v", kind, err)
}
return id
}
func insertDutyDoc(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID int64, docID, taskKey string, authorID, reviewerID, approverID int64) {
t.Helper()
_, err := pool.Exec(ctx, `
INSERT INTO document_records (
doc_id, project_id, task_key, title, stage, version,
current_state, is_locked, is_template_only,
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, 'V0.5', 1,
'drafting', false, false,
$5, '编制工程师', $6, '审核负责人', $7, '批准负责人',
now(), now()
)
`, docID, projectID, taskKey, taskKey+" doc", authorID, reviewerID, nullIfZero(approverID))
if err != nil {
t.Fatalf("insert doc %s: %v", docID, err)
}
}
func assertDocReviewer(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) {
t.Helper()
var got int64
if err := pool.QueryRow(ctx, `SELECT COALESCE(reviewer_id, 0) FROM document_records WHERE doc_id = $1`, docID).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("%s reviewer_id=%d want %d", docID, got, want)
}
}
+3
View File
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/docduty"
)
// WorkflowDocumentSummary 项目工作流受控文档列表项。
@@ -51,6 +53,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
}
if e.pool != nil {
_, _, _ = docduty.HealProjectDocuments(ctx, e.pool, projectID)
rows, err := e.pool.Query(ctx, `
SELECT doc_id, project_id, task_key, title, current_state, is_template_only,
COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at,
@@ -0,0 +1,48 @@
package server
import (
"context"
"testing"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docrevision"
)
func TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
const (
authorID int64 = 170
reviewerID int64 = 171
strangerID int64 = 99
docID = "doc_proj_duty_write"
)
if _, err := srv.svc.DocGov().CreateOrGetDocument(
ctx, docID, 6145, "intake", "摸底", "V0.1", "",
authorID, "编制人A", reviewerID, "审核人B", 0, "",
); err != nil {
t.Fatal(err)
}
ref := &docRevisionRef{
Kind: "controlled", ProjectID: 6145, LifecycleState: "drafting", AuthorID: authorID,
}
author := &auth.UserAccount{ID: authorID, Name: "编制人A"}
if err := srv.authorizeDocRevision(ctx, author, ref, true); err != nil && err != authz.ErrForbidden {
// 无 project_members 时可能因 authorizer 策略拒绝项目访问;编制人写权仍由 AuthorID 门闩覆盖。
if err != docrevision.ErrForbidden {
t.Fatalf("author write unexpected err: %v", err)
}
}
reviewer := &auth.UserAccount{ID: reviewerID, Name: "审核人B"}
if err := srv.authorizeDocRevision(ctx, reviewer, ref, true); err == nil {
t.Fatal("reviewer B must not write controlled body")
}
stranger := &auth.UserAccount{ID: strangerID, Name: "局外人C"}
if err := srv.authorizeDocRevision(ctx, stranger, ref, true); err == nil {
t.Fatal("non-member C must not write controlled body")
}
}
+17 -6
View File
@@ -10,9 +10,10 @@ import (
)
type projectMemberRequest struct {
UserID int64 `json:"user_id"`
ProjectRole string `json:"project_role"`
Status string `json:"status,omitempty"`
UserID int64 `json:"user_id"`
ProjectRole string `json:"project_role"`
DocumentDuty *string `json:"document_duty,omitempty"`
Status string `json:"status,omitempty"`
}
func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +82,10 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
if req.ProjectRole == "" {
req.ProjectRole = authz.RoleMember
}
if err := s.authorizer.UpsertProjectMember(r.Context(), projectID, req.UserID, req.ProjectRole); err != nil {
if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{
ProjectRole: req.ProjectRole,
DocumentDuty: req.DocumentDuty,
}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@@ -97,8 +101,15 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id required"}`, http.StatusBadRequest)
return
}
if req.ProjectRole != "" {
if err := s.authorizer.UpsertProjectMember(r.Context(), projectID, req.UserID, req.ProjectRole); err != nil {
if req.ProjectRole != "" || req.DocumentDuty != nil {
role := req.ProjectRole
if role == "" {
role = authz.RoleMember
}
if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{
ProjectRole: role,
DocumentDuty: req.DocumentDuty,
}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}