diff --git a/internal/authz/authorizer.go b/internal/authz/authorizer.go index b9b99c6c..8dba3bd2 100644 --- a/internal/authz/authorizer.go +++ b/internal/authz/authorizer.go @@ -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)。 diff --git a/internal/authz/service_test.go b/internal/authz/service_test.go index 476238ae..30dfb7ab 100644 --- a/internal/authz/service_test.go +++ b/internal/authz/service_test.go @@ -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") + } } diff --git a/internal/bizdev/document_duty_resolve_test.go b/internal/bizdev/document_duty_resolve_test.go new file mode 100644 index 00000000..e519fc78 --- /dev/null +++ b/internal/bizdev/document_duty_resolve_test.go @@ -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{} +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index b2e5c60c..8b5e4220 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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 +} diff --git a/internal/docduty/resolve.go b/internal/docduty/resolve.go new file mode 100644 index 00000000..e27cc064 --- /dev/null +++ b/internal/docduty/resolve.go @@ -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 +} diff --git a/internal/docduty/resolve_test.go b/internal/docduty/resolve_test.go new file mode 100644 index 00000000..73c29a55 --- /dev/null +++ b/internal/docduty/resolve_test.go @@ -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") + } +} diff --git a/internal/docduty/store.go b/internal/docduty/store.go new file mode 100644 index 00000000..e811ad2f --- /dev/null +++ b/internal/docduty/store.go @@ -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 +} diff --git a/internal/docduty/store_pg_test.go b/internal/docduty/store_pg_test.go new file mode 100644 index 00000000..a92e5f5d --- /dev/null +++ b/internal/docduty/store_pg_test.go @@ -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) + } +} diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index b27810ce..161cb927 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -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, diff --git a/internal/server/document_revision_duty_write_test.go b/internal/server/document_revision_duty_write_test.go new file mode 100644 index 00000000..9297c1c9 --- /dev/null +++ b/internal/server/document_revision_duty_write_test.go @@ -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") + } +} diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index 8825291b..472784b1 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -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 }