package service import ( "crypto/rand" "encoding/hex" "strconv" "strings" "git.gozeros.com/wsk/eightbf-analysis-go/internal/domain" "git.gozeros.com/wsk/eightbf-analysis-go/internal/repo" ) // CreateJobRequest 创建会商作业入参。 type CreateJobRequest struct { RequestID string `json:"requestId"` TaskID string `json:"taskId"` DraftID string `json:"draftId"` DraftKey string `json:"draftKey"` Model string `json:"model"` OrchestrationMode string `json:"orchestrationMode"` Title string `json:"title"` Description string `json:"description"` OwnerAccountID string `json:"ownerAccountId"` ManualIntervention *bool `json:"manualIntervention"` Agents []map[string]interface{} `json:"agents"` Payload map[string]interface{} `json:"payload"` Access map[string]interface{} `json:"access"` } type ResumeJobRequest struct { Answer string `json:"answer"` Reason string `json:"reason"` } type PatchJobPlanRequest struct { ManualIntervention *bool `json:"manualIntervention"` Model *string `json:"model"` } type InjectJobRequest struct { Text string `json:"text"` } // JobService 会商作业编排(不直接调大模型)。 type JobService struct { Repo *repo.JobRepo Access *AccessService } func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.AnalysisJob, error) { if req == nil { req = &CreateJobRequest{} } manual := true if req.ManualIntervention != nil { manual = *req.ManualIntervention } owner := blankToNull(req.OwnerAccountID) if owner == "" && caller.AccountID != "" { owner = caller.AccountID } mode := blankToNull(req.OrchestrationMode) if mode == "" { mode = "roundtable" } job := &domain.AnalysisJob{ ID: "job_" + randHex(8), RequestID: firstNonEmpty(blankToNull(req.RequestID), "req_"+randHex(6)), TaskID: blankToNull(req.TaskID), DraftID: blankToNull(req.DraftID), DraftKey: blankToNull(req.DraftKey), Status: domain.StatusQueued, Phase: "queued", OrchestrationMode: mode, Model: strings.TrimSpace(req.Model), OwnerAccountID: owner, Agents: normalizeAgents(req.Agents), Dialogues: []map[string]interface{}{}, Access: orEmptyMap(req.Access), Plan: map[string]interface{}{ "seatIndex": 0, "manualIntervention": manual, "seedMessage": nil, "pendingInject": nil, "awaitReason": nil, }, Progress: map[string]interface{}{ "percent": 0, "message": "queued", "title": req.Title, "description": req.Description, }, } if req.Payload != nil { job.Progress["payload"] = req.Payload } if err := s.Repo.Insert(job); err != nil { return nil, err } return s.Access.ProjectForCaller(job, caller), nil } func (s *JobService) Get(id string, caller Caller) (*domain.AnalysisJob, error) { job, err := s.Repo.FindByID(id) if err != nil || job == nil { return job, err } if !s.Access.CanRead(job, caller) { return nil, Forbidden("无权查看该会商作业") } return s.Access.ProjectForCaller(job, caller), nil } func (s *JobService) GetByTaskID(taskID string, latestOnly bool, caller Caller) (*domain.AnalysisJob, error) { var job *domain.AnalysisJob var err error if latestOnly { job, err = s.Repo.FindLatestByTaskID(taskID) } else { job, err = s.Repo.FindActiveByTaskID(taskID) if err == nil && job == nil { job, err = s.Repo.FindLatestByTaskID(taskID) } } if err != nil || job == nil { return job, err } if !s.Access.CanRead(job, caller) { return nil, Forbidden("无权查看该会商作业") } return s.Access.ProjectForCaller(job, caller), nil } func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, error) { job, err := s.Repo.FindByID(id) if err != nil || job == nil { return job, err } if !s.Access.CanCancel(job, caller) { return nil, Forbidden("无权取消该会商作业(仅任务责任人/创建人/管理员)") } if domain.IsTerminal(job.Status) { return s.Access.ProjectForCaller(job, caller), nil } _, _ = s.Repo.MarkCancel(id) job.Status = domain.StatusCancelled job.Phase = "cancelled" job.Error = "cancelled by client" p := job.EnsureProgress() p["message"] = "cancelled" if err := s.Repo.UpdateProgress(job); err != nil { return nil, err } return s.Access.ProjectForCaller(job, caller), nil } func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*domain.AnalysisJob, error) { job, err := s.Repo.FindByID(id) if err != nil || job == nil { return job, err } if !s.Access.CanMutate(job, caller) { return nil, Forbidden("无权续跑该会商作业") } if job.Status != domain.StatusAwaitingInput { return nil, BadState("当前状态不是等待输入,无法 resume(status=" + job.Status + ")") } answer := "" if body != nil { answer = strings.TrimSpace(body.Answer) } plan := job.EnsurePlan() if answer != "" { appendUserDialogue(job, answer) prev := asStr(plan["pendingInject"]) merged := answer if strings.TrimSpace(prev) != "" { merged = prev + "\n\n" + answer } plan["pendingInject"] = "用户对澄清的回复:\n" + merged } plan["awaitReason"] = nil job.PendingClarification = "" job.Status = domain.StatusQueued job.Phase = "resume_queued" p := job.EnsureProgress() p["message"] = "已收到补充,准备继续" if err := s.Repo.UpdateProgress(job); err != nil { return nil, err } fresh, err := s.Repo.FindByID(id) if err != nil { return nil, err } return s.Access.ProjectForCaller(fresh, caller), nil } func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Caller) (*domain.AnalysisJob, error) { job, err := s.Repo.FindByID(id) if err != nil || job == nil { return job, err } if !s.Access.CanMutate(job, caller) { return nil, Forbidden("无权修改该会商作业设置") } if domain.IsTerminal(job.Status) { return nil, BadState("作业已结束,无法修改设置") } if body == nil { return s.Access.ProjectForCaller(job, caller), nil } touched := false plan := job.EnsurePlan() progress := job.EnsureProgress() if body.Model != nil { model := strings.TrimSpace(*body.Model) job.Model = model if model == "" { progress["message"] = "已清除模型指定(用默认)" } else { progress["message"] = "已切换模型为 " + model } touched = true } if body.ManualIntervention != nil { manual := *body.ManualIntervention plan["manualIntervention"] = manual if manual { progress["message"] = "已开启人工干预(下一席结束后将等待确认)" } else { progress["message"] = "已关闭人工干预(将自动连跑)" } touched = true atGate := job.Status == domain.StatusAwaitingInput && asStr(plan["awaitReason"]) == "manual_continue" if !manual && atGate { plan["awaitReason"] = nil job.PendingClarification = "" job.Status = domain.StatusQueued job.Phase = "resume_queued" progress["message"] = "已关闭人工干预,自动继续下一席" } } if !touched { return s.Access.ProjectForCaller(job, caller), nil } if err := s.Repo.UpdateProgress(job); err != nil { return nil, err } fresh, err := s.Repo.FindByID(id) if err != nil { return nil, err } return s.Access.ProjectForCaller(fresh, caller), nil } func (s *JobService) Inject(id string, body *InjectJobRequest, caller Caller) (*domain.AnalysisJob, error) { job, err := s.Repo.FindByID(id) if err != nil || job == nil { return job, err } if !s.Access.CanMutate(job, caller) { return nil, Forbidden("无权向该会商注入消息") } text := "" if body != nil { text = strings.TrimSpace(body.Text) } if text == "" { return nil, BadArg("注入内容不能为空") } if domain.IsTerminal(job.Status) { return nil, BadState("作业已结束,无法注入") } appendUserDialogue(job, text) plan := job.EnsurePlan() prev := asStr(plan["pendingInject"]) merged := text if strings.TrimSpace(prev) != "" { merged = prev + "\n\n" + text } plan["pendingInject"] = "用户向会商插入的补充说明:\n" + merged p := job.EnsureProgress() p["message"] = "已收到用户补充,将在下一轮派活时带入" if err := s.Repo.UpdateProgress(job); err != nil { return nil, err } fresh, err := s.Repo.FindByID(id) if err != nil { return nil, err } return s.Access.ProjectForCaller(fresh, caller), nil } func appendUserDialogue(job *domain.AnalysisJob, text string) { list := job.EnsureDialogues() bubble := map[string]interface{}{ "id": "dlg_" + job.ID + "_user_" + strconv.Itoa(len(list)+1), "role": "user", "sender": "我", "content": text, "status": "done", "phaseLabel": "", } job.Dialogues = append(list, bubble) } func normalizeAgents(agents []map[string]interface{}) []map[string]interface{} { if len(agents) > 0 { out := make([]map[string]interface{}, 0, len(agents)) for _, a := range agents { copy := cloneMap(a) if _, ok := copy["id"]; !ok { if aid, ok := copy["agentId"]; ok { copy["id"] = aid } } if _, ok := copy["status"]; !ok { copy["status"] = "idle" } out = append(out, copy) } return out } return []map[string]interface{}{ {"id": "local-flow-stub-intel", "name": "联调桩-情报采集", "status": "idle"}, {"id": "local-flow-stub-plan", "name": "联调桩-筹划规划", "status": "idle"}, } } func blankToNull(s string) string { return strings.TrimSpace(s) } func firstNonEmpty(a, b string) string { if a != "" { return a } return b } func orEmptyMap(m map[string]interface{}) map[string]interface{} { if m == nil { return map[string]interface{}{} } return m } func randHex(nBytes int) string { b := make([]byte, nBytes) _, _ = rand.Read(b) return hex.EncodeToString(b) }