chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)

Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gozerosandCursor committed 2026-11-03 00:13:46 +08:00
commit 0c45b86d67
29 files changed
+3789

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
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)
}