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:
@@ -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)。
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user