Empty assigned heal now clears only exited or non-member reviewer/approver holders and never blanks author. Explicit reconcile uses the pre-update duty plus a project advisory lock so viewer demotion and transfers are not left to generic heal. Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
4.9 KiB
Go
183 lines
4.9 KiB
Go
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{}, clearInactive bool) (Holder, bool) {
|
|
if assigned.UserID > 0 {
|
|
if current.UserID == assigned.UserID && current.Name == assigned.Name {
|
|
return current, false
|
|
}
|
|
return assigned, true
|
|
}
|
|
if current.UserID <= 0 {
|
|
return current, false
|
|
}
|
|
if _, ok := memberIDs[current.UserID]; ok {
|
|
return current, false
|
|
}
|
|
if !clearInactive {
|
|
return current, false
|
|
}
|
|
return Holder{}, true
|
|
}
|
|
|
|
// HealBinding 以项目 document_duty 为权威。assigned 非零则对齐(错 ID 或同 ID 旧名称)。
|
|
// assigned 为空时:审核/批准仅清空已退出或非成员的失效 holder;在任成员保留以兼容未初始化 duty 的旧项目。
|
|
// 编制槽位即使 assigned 为空也不清空,避免无意去掉 owner/author。
|
|
func HealBinding(current, assigned Binding, memberIDs map[int64]struct{}) (Binding, bool) {
|
|
out := current
|
|
changed := false
|
|
if next, ok := healSlot(current.Author, assigned.Author, memberIDs, false); ok {
|
|
out.Author = next
|
|
changed = true
|
|
}
|
|
if next, ok := healSlot(current.Reviewer, assigned.Reviewer, memberIDs, true); ok {
|
|
out.Reviewer = next
|
|
changed = true
|
|
}
|
|
if next, ok := healSlot(current.Approver, assigned.Approver, memberIDs, true); ok {
|
|
out.Approver = next
|
|
changed = true
|
|
}
|
|
return out, changed
|
|
}
|