diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 78869523..894eb744 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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 {