feat(bizdev): prefer instance-governed duties when generating docs

Inject InstanceDutySource, resolve governed bindings, lock duties after
create, and skip in-memory HealBinding overwrite for governed docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 09:59:38 +00:00
co-authored by Cursor
parent 42a3b1e56c
commit 5b9b46ee8d
+80 -5
View File
@@ -51,6 +51,22 @@ type Service struct {
stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error)
// createDocumentHook 仅测试注入:覆盖账本创建失败路径。
createDocumentHook func(ctx context.Context, docID string, projectID int64, taskKey, title, stage, workDir string, authorID int64, authorName string, reviewerID int64, reviewerName string, approverID int64, approverName string) (*docgov.DocumentRecord, error)
// instanceDuty 可选:活跃工作流实例对 taskKey 的治理职责(由 app 装配,避免导入 projectinstance)。
instanceDuty InstanceDutySource
}
// InstanceDutySource 返回项目当前活跃实例对 taskKey 的治理结论。
// governed=false 表示未受实例治理,调用方应回退项目默认职责逻辑。
type InstanceDutySource interface {
ResolveGovernedDuty(ctx context.Context, projectID int64, taskKey string) (author, reviewer, approver int64, dutyPolicy string, governed bool, err error)
}
// SetInstanceDutySource 注入实例职责解析源(组合根可选装配)。
func (s *Service) SetInstanceDutySource(src InstanceDutySource) {
if s == nil {
return
}
s.instanceDuty = src
}
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
@@ -456,7 +472,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
}
}
duties := resolveTaskDocumentDuties(ctx, s.pool, req)
duties, dutyPolicy, governed := resolveTaskDocumentDuties(ctx, s.pool, req, s.instanceDuty)
authorID, authorName := duties.Author.UserID, duties.Author.Name
reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name
approverID, approverName := duties.Approver.UserID, duties.Approver.Name
@@ -500,6 +516,21 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
DocRecord: docRec,
}, createErr
}
if governed && docRec != nil {
if lockErr := s.docGov.LockDocumentDuty(ctx, docRec.DocID, dutyPolicy); lockErr != nil {
log.Printf("[docgov] lock document duty %s failed: %v", docRec.DocID, lockErr)
return &TaskRunResult{
TaskKey: req.TaskKey,
ArtifactTitle: artifactTitle,
WorkDir: req.WorkDir,
Success: false,
ErrorReason: "lock document duty failed: " + lockErr.Error(),
DocRecord: docRec,
}, lockErr
}
docRec.DutyLocked = true
docRec.DutyPolicy = dutyPolicy
}
if s.pool != nil && req.ProjectID > 0 {
assigned, _, healErr := docduty.HealProjectDocuments(ctx, s.pool, req.ProjectID)
if healErr != nil {
@@ -513,7 +544,8 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
DocRecord: docRec,
}, healErr
}
if docRec != nil {
// 受实例治理的文档不得用项目默认职责覆盖内存态返回值。
if !governed && docRec != nil {
members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID)
if loadErr != nil {
log.Printf("[docduty] load members project %d after generate failed: %v", req.ProjectID, loadErr)
@@ -1339,7 +1371,35 @@ func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyW
return s.squad.ResumeOrModifyWorkspace(ctx, req)
}
func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest) docduty.Binding {
func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest, instanceDuty InstanceDutySource) (docduty.Binding, string, bool) {
if instanceDuty != nil && req.ProjectID > 0 && strings.TrimSpace(req.TaskKey) != "" {
authorID, reviewerID, approverID, dutyPolicy, governed, err := instanceDuty.ResolveGovernedDuty(ctx, req.ProjectID, req.TaskKey)
if err == nil && governed {
bound := docduty.Binding{
Author: docduty.Holder{UserID: authorID},
Reviewer: docduty.Holder{UserID: reviewerID},
Approver: docduty.Holder{UserID: approverID},
}
if pool != nil {
if members, creator, loadErr := docduty.LoadProjectMembers(ctx, pool, req.ProjectID); loadErr == nil {
bound.Author.Name = holderName(members, creator, authorID, req.AuthorName)
bound.Reviewer.Name = holderName(members, creator, reviewerID, req.ReviewerName)
bound.Approver.Name = holderName(members, creator, approverID, req.ApproverName)
}
}
if bound.Author.Name == "" {
bound.Author.Name = strings.TrimSpace(req.AuthorName)
}
if bound.Reviewer.Name == "" {
bound.Reviewer.Name = strings.TrimSpace(req.ReviewerName)
}
if bound.Approver.Name == "" {
bound.Approver.Name = strings.TrimSpace(req.ApproverName)
}
return bound, dutyPolicy, true
}
}
reqDuties := docduty.RequestDuties{
AuthorID: req.AuthorID, AuthorName: req.AuthorName,
ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName,
@@ -1353,11 +1413,26 @@ func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req Task
if bound.Author.UserID == 0 && req.UserID > 0 {
bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
}
return bound
return bound, "", false
}
}
creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
return docduty.Resolve(nil, creator, reqDuties)
return docduty.Resolve(nil, creator, reqDuties), "", false
}
func holderName(members []docduty.Member, creator docduty.Holder, userID int64, fallback string) string {
if userID <= 0 {
return strings.TrimSpace(fallback)
}
if creator.UserID == userID && strings.TrimSpace(creator.Name) != "" {
return strings.TrimSpace(creator.Name)
}
for _, m := range members {
if m.UserID == userID && strings.TrimSpace(m.Name) != "" {
return strings.TrimSpace(m.Name)
}
}
return strings.TrimSpace(fallback)
}
func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding {