Files
eightbf-analysis-go/internal/service/job_service.go
T
gozerosandCursor 0c45b86d67 chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)
Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 00:13:46 +08:00

353 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}