feat: 换到 Gin 主流栈并补齐运维能力

HTTP 用 Gin,配置用 Viper,日志用 zap,数据用 sqlx;保留原有接口与会商行为。
顺带补上就绪探活、请求号、滚动日志、systemd 与 Dockerfile。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gozerosandCursor committed 2026-11-04 09:51:41 +08:00
1 parent 0c45b86d67
commit bd427b57a1
26 files changed
+1213 -302

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# 多阶段构建:产出可直接跑的静态风格二进制(仍动态链接 libc 即可)
FROM golang:1.23-bookworm AS build
WORKDIR /src
ENV GOPROXY=https://goproxy.cn,direct
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/eightbf-analysis-go ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app /var/log/eightbf-analysis
WORKDIR /app
COPY --from=build /out/eightbf-analysis-go /app/eightbf-analysis-go
COPY schema.sql /app/schema.sql
ENV EBA_SCHEMA_PATH=/app/schema.sql \
SERVER_PORT=18097 \
LOG_LEVEL=info
EXPOSE 18097
ENTRYPOINT ["/app/eightbf-analysis-go"]
+18 -10
View File
@@ -45,30 +45,38 @@ go build -o bin/eightbf-analysis-go ./cmd/server
## 接口(与 Java 一致)
- `GET /api/v1/analysis/health`
- `GET /api/v1/analysis/health` — 存活
- `GET /api/v1/analysis/ready` — 就绪(含 MySQL ping,失败 503)
- `POST /api/v1/analysis/jobs`
- `GET /api/v1/analysis/jobs/{id}`
- `GET /api/v1/analysis/jobs?taskId=&latest=`
- `POST /api/v1/analysis/jobs/{id}/cancel|resume|plan|inject`
- `GET /api/v1/analysis/jobs/{id}/result`
身份头:`X-Account-Id`、`X-Analysis-Role`。
身份头:`X-Account-Id`、`X-Analysis-Role`。响应头带回 `X-Request-Id`。
## 目录
```text
cmd/server 入口
internal/api HTTP
cmd/server 入口(优雅停机)
internal/api Gin 路由 + 审计日志 + 中间件
internal/logging zap / 滚动文件
internal/service 编排 / 鉴权投影 / DemoWorker
internal/worker LiveWorker / LiveChainRunner / 提示词
internal/deerflow 登录、SSE、模型校验
internal/repo MySQL
internal/repo sqlx + MySQL
internal/domain 作业实体与状态常量
internal/config 环境变量
docs/ 设计要求(自 Java 同步)+ env.sample
internal/config Viper 读环境变量 + 启动校验
deploy/ systemd unit
docs/ 设计要求、运维、env.sample
Dockerfile
```
## 日志
## 日志与运维
遵循设计要求 §11:业务日志带 `jobId=`;禁止打印 token/密码/完整 dialogues。
当前写 stdout,正式环境用 systemd/Docker 收集或挂滚动文件。
见 [docs/运维.md](./docs/运维.md)。要点:
- `LOG_LEVEL` / `LOG_FILE`(滚动,默认保留 14 天)
- 写操作 INFO 审计:`jobId` / `accountId` / `reqId`
- `EBA_STRICT_CONFIG=true` 禁止 live 默认弱口令启动
- SIGTERM 优雅停机;systemd 见 `deploy/eightbf-analysis-go.service`
+91 -21
View File
@@ -1,18 +1,27 @@
// 会商后台进程入口。
//
// 启动顺序不能颠倒:先日志(后面的失败才看得到)→ 校验配置 → 连库建表 → 挂 Gin → 按模式起一个 Worker → 听信号停机。
// 边界:live 与 demo 只起一个 Worker。两进程不要同时消费同一库的 queued。
package main
import (
"database/sql"
"log"
"context"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/api"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/worker"
@@ -20,59 +29,120 @@ import (
func main() {
cfg := config.Load()
log.Printf("eightbf-analysis-go starting port=%d mode=%s auth=%v demo=%v",
cfg.Port, cfg.AnalysisMode, cfg.AuthEnabled, cfg.DemoEnabled)
closeLog := logging.Setup(logging.Options{
Level: cfg.LogLevel,
File: cfg.LogFile,
MaxMB: cfg.LogMaxMB,
MaxAge: cfg.LogMaxAgeDays,
MaxBak: cfg.LogMaxBackups,
})
defer closeLog()
db, err := sql.Open("mysql", cfg.DBDSN)
warns, err := cfg.Validate()
if err != nil {
log.Fatalf("open db: %v", err)
logging.L.Errorw("配置校验失败", "err", err)
os.Exit(1)
}
for _, w := range warns {
logging.L.Warnw("配置告警", "msg", w)
}
logging.L.Infow("eightbf-analysis-go 启动",
"version", cfg.Version,
"port", cfg.Port,
"mode", cfg.AnalysisMode,
"auth", cfg.AuthEnabled,
"demo", cfg.DemoEnabled,
"logFile", cfg.LogFile,
)
db, err := sqlx.Open("mysql", cfg.DBDSN)
if err != nil {
logging.L.Errorw("打开数据库失败", "err", err)
os.Exit(1)
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
log.Fatalf("ping db: %v(请检查 EBA_DB_DSN / EBA_DB_*)", err)
logging.L.Errorw("数据库不可达", "err", err)
os.Exit(1)
}
defer db.Close()
jobRepo := repo.NewJobRepo(db)
if schema, err := os.ReadFile("schema.sql"); err != nil {
log.Printf("未找到 schema.sql,跳过自动建表: %v", err)
if schema, err := os.ReadFile(cfg.SchemaPath); err != nil {
logging.L.Warnw("未找到 schema,跳过自动建表", "path", cfg.SchemaPath, "err", err)
} else {
jobRepo.ApplySchema(string(schema))
}
access := &service.AccessService{AuthEnabled: cfg.AuthEnabled}
jobs := &service.JobService{Repo: jobRepo, Access: access}
h := &api.Handler{Jobs: jobs}
h := &api.Handler{
Jobs: jobs,
DB: db,
Version: cfg.Version,
Started: time.Now(),
}
mux := http.NewServeMux()
h.Routes(mux)
// Release 模式关掉 Gin 自带的逐条访问日志。作业页会轮询 GET,那些行对排障没有帮助。
if strings.EqualFold(cfg.LogLevel, "debug") {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
engine.Use(api.RequestID(), api.CORS(), api.Recovery())
h.Register(engine)
if cfg.AnalysisMode == "live" && !cfg.DemoEnabled {
auth := deerflow.NewAuth(cfg)
df := deerflow.NewClient(cfg, auth)
runner := &worker.LiveChainRunner{DF: df, Repo: jobRepo, Coord: cfg.DeerFlowCoordinator}
lw := &worker.LiveWorker{
Repo: jobRepo,
Runner: runner,
PollEvery: time.Duration(cfg.LivePollMS) * time.Millisecond,
Repo: jobRepo,
Runner: runner,
PollEvery: time.Duration(cfg.LivePollMS) * time.Millisecond,
MaxInflight: int32(cfg.LiveMaxInflight),
}
lw.Start()
log.Printf("LiveWorker 已启动 poll=%dms", cfg.LivePollMS)
logging.L.Infow("LiveWorker 已启动", "pollMs", cfg.LivePollMS, "maxInflight", cfg.LiveMaxInflight)
} else if cfg.DemoEnabled || cfg.AnalysisMode == "demo" {
dw := &service.DemoWorker{
Repo: jobRepo,
Every: time.Duration(cfg.DemoTickMS) * time.Millisecond,
}
dw.Start()
log.Printf("DemoWorker 已启动 tick=%dms", cfg.DemoTickMS)
logging.L.Infow("DemoWorker 已启动", "tickMs", cfg.DemoTickMS)
} else {
log.Printf("警告:未启动任何 Worker(mode=%s demo=%v)", cfg.AnalysisMode, cfg.DemoEnabled)
logging.L.Warnw("未启动任何 Worker", "mode", cfg.AnalysisMode, "demo", cfg.DemoEnabled)
}
addr := ":" + strconv.Itoa(cfg.Port)
log.Printf("监听 %s (健康检查 GET /api/v1/analysis/health)", addr)
if err := http.ListenAndServe(addr, api.WithCORS(mux)); err != nil {
log.Fatal(err)
srv := &http.Server{
Addr: addr,
Handler: engine,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logging.L.Infow("HTTP 监听中", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logging.L.Errorw("HTTP 服务异常退出", "err", err)
os.Exit(1)
}
}()
// 优雅停机:停接收新连接,给进行中请求一点时间(Runner 仍可能在后台跑完当前席)。
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
sig := <-stop
logging.L.Infow("收到退出信号,开始优雅停机", "signal", sig.String())
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logging.L.Warnw("优雅停机超时或失败", "err", err)
}
logging.L.Infow("服务已退出")
}
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=eightbf-analysis-go (8BF 多岗位会商后台)
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/eightbf-analysis-go
EnvironmentFile=-/etc/eightbf-analysis-go.env
ExecStart=/opt/eightbf-analysis-go/bin/eightbf-analysis-go
Restart=on-failure
RestartSec=3
# 优雅退出:先 SIGTERM,等 20s
TimeoutStopSec=20
KillMode=mixed
# 建议在 EnvironmentFile 中设置:
# SERVER_PORT=18097
# ANALYSIS_MODE=live
# ANALYSIS_AUTH_ENABLED=true
# EBA_STRICT_CONFIG=true
# EBA_DB_DSN=...
# EBA_DF_BASE_URL=...
# EBA_DF_USER=...
# EBA_DF_PASSWORD=...
# LOG_LEVEL=info
# LOG_FILE=/var/log/eightbf-analysis/app-go.log
# EBA_SCHEMA_PATH=/opt/eightbf-analysis-go/schema.sql
[Install]
WantedBy=multi-user.target
+30 -16
View File
@@ -1,24 +1,38 @@
# eightbf-analysis / eightbf-analysis-go 内网环境变量样例
# eightbf-analysis-go 环境变量样例(export 或 systemd EnvironmentFile)
# 勿把真实密码提交进 Git。
# MySQL(正式必填)
# Java 可用 JDBC;Go 优先 EBA_DB_DSN,也能解析 EBA_DB_URL(jdbc)
EBA_DB_URL=jdbc:mysql://<mysql-host>:3306/eightbf_analysis?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
EBA_DB_USER=eba
EBA_DB_PASSWORD=
# Go 推荐:
# EBA_DB_DSN=eba:xxx@tcp(mysql-host:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true
# —— 服务 ——
SERVER_PORT=18097
ANALYSIS_MODE=live
ANALYSIS_AUTH_ENABLED=false
EBA_STRICT_CONFIG=false
EBA_VERSION=0.1.0
EBA_SCHEMA_PATH=schema.sql
# DeerFlow Gateway(ANALYSIS_MODE=live 时必填)
EBA_DF_BASE_URL=http://<deerflow-gateway>:8001
# —— 日志(设计要求 §11)——
LOG_LEVEL=info
# LOG_FILE=/var/log/eightbf-analysis/app-go.log
LOG_MAX_MB=100
LOG_MAX_AGE_DAYS=14
LOG_MAX_BACKUPS=14
# —— MySQL ——
# Go 推荐 DSN;也可 EBA_DB_URL(jdbc) + USER/PASSWORD
EBA_DB_DSN=eba:xxx@tcp(127.0.0.1:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true
# EBA_DB_URL=jdbc:mysql://127.0.0.1:3306/eightbf_analysis?...
# EBA_DB_USER=eba
# EBA_DB_PASSWORD=
# —— DeerFlow(live 必填)——
EBA_DF_BASE_URL=http://127.0.0.1:8001
EBA_DF_USER=admin
EBA_DF_PASSWORD=
EBA_DF_COORDINATOR=roundtable-coordinator
EBA_DF_STREAM_IDLE_TIMEOUT_MS=180000
# 可选:改端口
# SERVER_PORT=18097
# Go 运行模式
# ANALYSIS_MODE=live
# ANALYSIS_AUTH_ENABLED=false
# —— Worker ——
EBA_WORKER_LIVE_POLL_MS=1000
EBA_WORKER_LIVE_MAX=2
EBA_WORKER_TICK_MS=400
# EBA_WORKER_DEMO_ENABLED=false
# 假进度:ANALYSIS_MODE=demo
+1 -1
View File
@@ -17,7 +17,7 @@
1. **进程互斥**:勿与 Java 实例同时抢同一 MySQL 的 `queued`(尚未做多实例领取锁)。
2. **schema 启动**:Go 启动读工作目录 `schema.sql`;部署时保证 cwd 或改绝对路径。
3. **SSE 细节**:DeerFlow 帧格式若有变,优先对照 Java `DeerFlowClient` 再改本仓 `internal/deerflow`。
4. **日志**:先 stdout;滚动文件按设计要求 §11 后续加 lumberjack 即可。
4. **日志 / 就绪 / 停机**:zap + 可选 `LOG_FILE` 滚动、`/ready`、SIGTERM 优雅停机;HTTP 为 Gin。详见 [运维.md](./运维.md)。
## 切换建议
+46
View File
@@ -0,0 +1,46 @@
# eightbf-analysis-go 运维要点
对齐 [服务端开发设计要求.md](./服务端开发设计要求.md) §9–§11。
## 健康检查
| 路径 | 含义 |
|------|------|
| `GET /api/v1/analysis/health` | 存活:进程在听即可 |
| `GET /api/v1/analysis/ready` | 就绪:至少 MySQL `Ping` 成功,否则 **503** |
Nginx / 编排探活建议:liveness → health;readiness → ready。
## 日志
| 变量 | 说明 |
|------|------|
| `LOG_LEVEL` | debug / info / warn / error,默认 info |
| `LOG_FILE` | 非空则 **stdout + 滚动文件**(zap + lumberjack,控制台键值格式) |
| `LOG_MAX_MB` | 单文件上限 MB,默认 100 |
| `LOG_MAX_AGE_DAYS` | 保留天数,默认 14 |
| `LOG_MAX_BACKUPS` | 备份份数,默认 14 |
业务键:`jobId=` `requestId=` `accountId=` `reqId=` `status=` `phase=` `model=`。
不写 token / 密码 / 完整 dialogues。
采集:systemd journal 或扫 `LOG_FILE` 目录 → Filebeat/Vector → Loki/ELK。
## 配置校验
- 启动时 `Validate()`:非法 mode/端口、空 DSN 直接退出。
- `EBA_STRICT_CONFIG=true`:live 仍用联调默认 DeerFlow 密码则 **拒绝启动**。
- 非 strict 仅 WARN。
## 优雅停机
捕获 SIGINT/SIGTERM → `http.Server.Shutdown`(最长 15s)。
进行中的 LiveRunner 可能仍在跑当前席;取消靠库内 `cancel_flag`。
## systemd
见 `deploy/eightbf-analysis-go.service`,环境文件建议 `/etc/eightbf-analysis-go.env`(勿提交 Git)。
## 单活约束
与 Java 实例、或第二份 Go 进程 **不要** 同时抢同一 MySQL 的 `queued`(尚无领取互斥)。
+46 -2
View File
@@ -2,6 +2,50 @@ module git.gozeros.com/wsk/eightbf-analysis-go
go 1.26.5
require github.com/go-sql-driver/mysql v1.8.1
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/spf13/viper v1.20.1
go.uber.org/zap v1.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require filippo.io/edwards25519 v1.1.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+126
View File
@@ -1,4 +1,130 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+193 -80
View File
@@ -3,153 +3,250 @@ package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
)
// Handler HTTP 入口,路径与 Java AnalysisJobController 对齐。
//
// 为什么用 Gin:路由、参数、中间件是这层的全部职责,业务判断仍在 JobService。
// 约定:成功业务体是 {code:0,message,data};health/ready 是给探活的扁平 JSON,不要套这层外壳。
type Handler struct {
Jobs *service.JobService
Jobs *service.JobService
DB *sqlx.DB
Version string
Started time.Time
}
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/analysis/health", h.health)
mux.HandleFunc("POST /api/v1/analysis/jobs", h.create)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}", h.get)
mux.HandleFunc("GET /api/v1/analysis/jobs", h.getByTask)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/cancel", h.cancel)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/resume", h.resume)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/plan", h.patchPlan)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/inject", h.inject)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}/result", h.result)
// Register 挂上 /api/v1/analysis 下的全部路径。方法+路径与 Java 保持一致,前端不用改。
func (h *Handler) Register(r *gin.Engine) {
g := r.Group("/api/v1/analysis")
g.GET("/health", h.health)
g.GET("/ready", h.ready)
g.POST("/jobs", h.create)
g.GET("/jobs/:id", h.get)
g.GET("/jobs", h.getByTask)
g.POST("/jobs/:id/cancel", h.cancel)
g.POST("/jobs/:id/resume", h.resume)
g.POST("/jobs/:id/plan", h.patchPlan)
g.POST("/jobs/:id/inject", h.inject)
g.GET("/jobs/:id/result", h.result)
}
func (h *Handler) caller(r *http.Request) service.Caller {
return service.NewCaller(r.Header.Get("X-Account-Id"), r.Header.Get("X-Analysis-Role"))
func (h *Handler) caller(c *gin.Context) service.Caller {
return service.NewCaller(c.GetHeader("X-Account-Id"), c.GetHeader("X-Analysis-Role"))
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
// health 存活探活:进程在听即可,不查依赖(给进程托管用)。
func (h *Handler) health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"service": "eightbf-analysis-go",
"version": h.Version,
"uptime": time.Since(h.Started).Round(time.Second).String(),
})
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
// ready 就绪:至少能 ping MySQL;失败返回 503,便于摘流量。
func (h *Handler) ready(c *gin.Context) {
body := gin.H{
"ok": true,
"service": "eightbf-analysis-go",
"checks": map[string]string{},
}
checks := body["checks"].(map[string]string)
if h.DB == nil {
checks["db"] = "not_configured"
body["ok"] = false
c.JSON(http.StatusServiceUnavailable, body)
return
}
if err := h.DB.PingContext(c.Request.Context()); err != nil {
checks["db"] = "fail"
body["ok"] = false
body["error"] = err.Error()
logging.L.Errorw("就绪检查失败", "check", "db", "err", err)
c.JSON(http.StatusServiceUnavailable, body)
return
}
checks["db"] = "ok"
c.JSON(http.StatusOK, body)
}
// create 新建一场会商。空 body 允许(字段都有服务端缺省);非法 JSON 直接 400,不再当成空请求吞掉。
func (h *Handler) create(c *gin.Context) {
var body service.CreateJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Create(&body, h.caller(r))
if err != nil {
h.mapErr(w, err)
if err := bindBody(c, &body); err != nil {
Fail(c, http.StatusBadRequest, 400, "请求体不是合法 JSON")
return
}
Created(w, job)
caller := h.caller(c)
job, err := h.Jobs.Create(&body, caller)
if err != nil {
h.mapErr(c, err)
return
}
logging.L.Infow("作业创建",
logging.KeyReqID, RequestIDFrom(c),
logging.KeyJobID, job.ID,
logging.KeyRequestID, job.RequestID,
logging.KeyTaskID, job.TaskID,
logging.KeyAccountID, caller.AccountID,
logging.KeyStatus, job.Status,
)
Created(c, job)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
job, err := h.Jobs.Get(id, h.caller(r))
func (h *Handler) get(c *gin.Context) {
job, err := h.Jobs.Get(c.Param("id"), h.caller(c))
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
OK(c, job)
}
func (h *Handler) getByTask(w http.ResponseWriter, r *http.Request) {
taskID := strings.TrimSpace(r.URL.Query().Get("taskId"))
// getByTask 用 taskId 找作业。latest=true 只取最近一场;否则先找还在跑的,没有再退回最近一场。
func (h *Handler) getByTask(c *gin.Context) {
taskID := strings.TrimSpace(c.Query("taskId"))
if taskID == "" {
Fail(w, http.StatusBadRequest, 400, "taskId required")
Fail(c, http.StatusBadRequest, 400, "taskId required")
return
}
latest := r.URL.Query().Get("latest") == "true"
job, err := h.Jobs.GetByTaskID(taskID, latest, h.caller(r))
latest := c.Query("latest") == "true"
job, err := h.Jobs.GetByTaskID(taskID, latest, h.caller(c))
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found for taskId")
Fail(c, http.StatusNotFound, 404, "job not found for taskId")
return
}
OK(w, job)
OK(c, job)
}
func (h *Handler) cancel(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Cancel(r.PathValue("id"), h.caller(r))
func (h *Handler) cancel(c *gin.Context) {
caller := h.caller(c)
job, err := h.Jobs.Cancel(c.Param("id"), caller)
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
logging.L.Infow("作业取消",
logging.KeyReqID, RequestIDFrom(c),
logging.KeyJobID, job.ID,
logging.KeyAccountID, caller.AccountID,
logging.KeyStatus, job.Status,
)
OK(c, job)
}
func (h *Handler) resume(w http.ResponseWriter, r *http.Request) {
func (h *Handler) resume(c *gin.Context) {
var body service.ResumeJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Resume(r.PathValue("id"), &body, h.caller(r))
if err := bindBody(c, &body); err != nil {
Fail(c, http.StatusBadRequest, 400, "请求体不是合法 JSON")
return
}
caller := h.caller(c)
job, err := h.Jobs.Resume(c.Param("id"), &body, caller)
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
logging.L.Infow("作业续跑",
logging.KeyReqID, RequestIDFrom(c),
logging.KeyJobID, job.ID,
logging.KeyAccountID, caller.AccountID,
logging.KeyStatus, job.Status,
logging.KeyPhase, job.Phase,
)
OK(c, job)
}
func (h *Handler) patchPlan(w http.ResponseWriter, r *http.Request) {
func (h *Handler) patchPlan(c *gin.Context) {
var body service.PatchJobPlanRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.PatchPlan(r.PathValue("id"), &body, h.caller(r))
if err := bindBody(c, &body); err != nil {
Fail(c, http.StatusBadRequest, 400, "请求体不是合法 JSON")
return
}
caller := h.caller(c)
job, err := h.Jobs.PatchPlan(c.Param("id"), &body, caller)
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
logging.L.Infow("作业改计划",
logging.KeyReqID, RequestIDFrom(c),
logging.KeyJobID, job.ID,
logging.KeyAccountID, caller.AccountID,
logging.KeyStatus, job.Status,
logging.KeyModel, job.Model,
)
OK(c, job)
}
func (h *Handler) inject(w http.ResponseWriter, r *http.Request) {
func (h *Handler) inject(c *gin.Context) {
var body service.InjectJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Inject(r.PathValue("id"), &body, h.caller(r))
if err := bindBody(c, &body); err != nil {
Fail(c, http.StatusBadRequest, 400, "请求体不是合法 JSON")
return
}
caller := h.caller(c)
job, err := h.Jobs.Inject(c.Param("id"), &body, caller)
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
// 不打印 inject 全文(脱敏),只留字数,确认请求到了即可。
logging.L.Infow("作业注入补充",
logging.KeyReqID, RequestIDFrom(c),
logging.KeyJobID, job.ID,
logging.KeyAccountID, caller.AccountID,
"chars", len([]rune(strings.TrimSpace(body.Text))),
)
OK(c, job)
}
func (h *Handler) result(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Get(r.PathValue("id"), h.caller(r))
func (h *Handler) result(c *gin.Context) {
job, err := h.Jobs.Get(c.Param("id"), h.caller(c))
if err != nil {
h.mapErr(w, err)
h.mapErr(c, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
Fail(c, http.StatusNotFound, 404, "job not found")
return
}
OK(w, map[string]interface{}{
OK(c, gin.H{
"id": job.ID,
"status": job.Status,
"result": job.Result,
@@ -158,32 +255,48 @@ func (h *Handler) result(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) mapErr(w http.ResponseWriter, err error) {
// mapErr 把业务错误翻成前端已经认的 HTTP 状态。未知错误才 500,并打全量 err。
func (h *Handler) mapErr(c *gin.Context, err error) {
var be *service.BizError
if errors.As(err, &be) {
switch {
case errors.Is(be.Kind, service.ErrForbidden):
Fail(w, http.StatusForbidden, 403, be.Msg)
logging.L.Warnw("权限拒绝",
logging.KeyReqID, RequestIDFrom(c),
"path", c.Request.URL.Path,
"msg", be.Msg,
)
Fail(c, http.StatusForbidden, 403, be.Msg)
case errors.Is(be.Kind, service.ErrBadState), errors.Is(be.Kind, service.ErrBadArg):
Fail(w, http.StatusBadRequest, 400, be.Msg)
logging.L.Infow("业务拒绝",
logging.KeyReqID, RequestIDFrom(c),
"path", c.Request.URL.Path,
"msg", be.Msg,
)
Fail(c, http.StatusBadRequest, 400, be.Msg)
default:
Fail(w, http.StatusInternalServerError, 500, be.Msg)
Fail(c, http.StatusInternalServerError, 500, be.Msg)
}
return
}
Fail(w, http.StatusInternalServerError, 500, err.Error())
logging.L.Errorw("接口内部错误",
logging.KeyReqID, RequestIDFrom(c),
"path", c.Request.URL.Path,
"err", err,
)
Fail(c, http.StatusInternalServerError, 500, err.Error())
}
// WithCORS 本机联调放开跨域(经反代同源时无影响)。
func WithCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
// bindBody 读取 JSON。没有 body(或只有空白)视为空对象,和以前「解码失败就当空请求」里合法的那一半对齐。
// 有内容但不是 JSON 则返回错误,由调用方回 400。
func bindBody(c *gin.Context, dst interface{}) error {
if c.Request.Body == nil {
return nil
}
dec := json.NewDecoder(c.Request.Body)
err := dec.Decode(dst)
if err == nil || errors.Is(err, io.EOF) {
return nil
}
return err
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// 只锁路由外壳:探活不碰库,坏 JSON 在进业务前被拒绝。
func TestHealthAndRejectsBadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(RequestID(), CORS(), Recovery())
h := &Handler{Version: "test", Started: time.Now()}
h.Register(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/analysis/health", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health %d %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Request-Id") == "" {
t.Fatal("响应应带回 X-Request-Id")
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/analysis/jobs", strings.NewReader("{"))
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("bad json %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/analysis/jobs", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing taskId %d %s", rec.Code, rec.Body.String())
}
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
)
const reqIDKey = "reqId"
// RequestID 为每个请求分配 reqId(可读头 X-Request-Id),并做慢请求 WARN。
//
// 约定:调用方已带 X-Request-Id 就沿用,方便和 Nginx / 前端对上同一次操作。
// 边界:轮询 GET 不打 INFO,只有超过 3 秒才 WARN,避免作业查询把故障日志淹没。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-Id")
if rid == "" {
rid = newReqID()
}
c.Header("X-Request-Id", rid)
c.Set(reqIDKey, rid)
start := time.Now()
c.Next()
elapsed := time.Since(start)
if elapsed >= 3*time.Second {
logging.L.Warnw("慢请求",
logging.KeyReqID, rid,
"method", c.Request.Method,
"path", c.Request.URL.Path,
"ms", elapsed.Milliseconds(),
)
}
}
}
// RequestIDFrom 取出中间件写入的请求号。
func RequestIDFrom(c *gin.Context) string {
if c == nil {
return ""
}
v, ok := c.Get(reqIDKey)
if !ok {
return ""
}
s, _ := v.(string)
return s
}
func newReqID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "req_" + hex.EncodeToString(b)
}
// CORS 本机联调放开跨域。经 Nginx 同源反代时浏览器不会走这段,多写无副作用。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "*")
h.Set("Access-Control-Expose-Headers", "X-Request-Id")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// Recovery 接住 handler panic,记一条错误日志并回 500,避免 Gin 默认文本把响应外壳弄乱。
func Recovery() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, rec interface{}) {
logging.L.Errorw("接口 panic",
logging.KeyReqID, RequestIDFrom(c),
"path", c.Request.URL.Path,
"err", rec,
)
Fail(c, http.StatusInternalServerError, 500, "internal error")
})
}
+10 -13
View File
@@ -1,31 +1,28 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// ApiResponse 与 Java 一致:{ code, message, data };成功 code=0。
//
// 探活 /health、/ready 不走这个外壳,编排系统要直接读 ok 字段。
type ApiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func OK(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, ApiResponse{Code: 0, Message: "ok", Data: data})
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Created(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusCreated, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Fail(w http.ResponseWriter, httpStatus, code int, msg string) {
writeJSON(w, httpStatus, ApiResponse{Code: code, Message: msg, Data: nil})
func Fail(c *gin.Context, httpStatus, code int, msg string) {
c.JSON(httpStatus, ApiResponse{Code: code, Message: msg, Data: nil})
}
+138 -56
View File
@@ -1,87 +1,168 @@
// Package config 从环境变量装载运行配置。
//
// 为什么用 Viper:Go 服务里读配置的常见入口,以后若要加配置文件不用再改调用方。
// 约定:当前只认环境变量,键名与 Java / 旧版 Go 完全一致(EBA_*、ANALYSIS_*、LOG_*、SERVER_PORT)。
// 边界:空字符串当成没配,回落到联调缺省;非法数字同样回落,不在这里把进程打崩(致命项交给 Validate)。
package config
import (
"os"
"strconv"
"fmt"
"strings"
"github.com/spf13/viper"
)
// Config 运行配置(环境变量优先,与 Java EBA_* / analysis.* 对齐)。
type Config struct {
Port int
// MySQL:优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
// DBDSN MySQL 连接串。优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
DBDSN string
AnalysisMode string // live | demo
AuthEnabled bool
DemoEnabled bool
LivePollMS int
DemoTickMS int
AnalysisMode string // live | demo。demo 时强制走假进度,不再打 DeerFlow。
AuthEnabled bool // false 时写操作不强制账号头,仅联调。
DemoEnabled bool // 真模型与假进度二选一;live 下只有显式打开才为 true。
LivePollMS int
DemoTickMS int
LiveMaxInflight int // 真模型并发上限。小于 1 时 Load 会抬回 2。
DeerFlowBaseURL string
DeerFlowUser string
DeerFlowPassword string
DeerFlowCoordinator string
DeerFlowStreamIdleTimeout int // 毫秒
DeerFlowStreamIdleTimeout int // 毫秒。流式读空闲超过这个值视为卡住。
LogLevel string
LogFile string
LogMaxMB int
LogMaxAgeDays int
LogMaxBackups int
// SchemaPath 建表脚本路径;空则尝试 ./schema.sql。
SchemaPath string
// StrictConfig true 时:live 使用联调默认/空 DeerFlow 口令直接拒绝启动。
StrictConfig bool
// Version 构建信息(也可用环境变量 EBA_VERSION;ldflags 可再覆盖二进制)。
Version string
}
// Load 从环境变量加载;缺省贴近本机联调。
//
// ANALYSIS_MODE=demo 时无视 worker 开关,一律假进度。
// ANALYSIS_MODE=live 且没有显式设置 EBA_WORKER_DEMO_ENABLED 时,强制关掉 demo,
// 避免壳环境里残留的默认值把真模型服务悄悄变成演示。
func Load() Config {
v := newEnv()
c := Config{
Port: envInt("SERVER_PORT", 18097),
AnalysisMode: env("ANALYSIS_MODE", "live"),
AuthEnabled: envBool("ANALYSIS_AUTH_ENABLED", false),
DemoEnabled: envBool("EBA_WORKER_DEMO_ENABLED", false),
LivePollMS: envInt("EBA_WORKER_LIVE_POLL_MS", 1000),
DemoTickMS: envInt("EBA_WORKER_TICK_MS", 400),
DeerFlowBaseURL: strings.TrimRight(env("EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
DeerFlowUser: env("EBA_DF_USER", "admin"),
DeerFlowPassword: env("EBA_DF_PASSWORD", "123ewq"),
DeerFlowCoordinator: env("EBA_DF_COORDINATOR", "roundtable-coordinator"),
DeerFlowStreamIdleTimeout: envInt("EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
Port: vInt(v, "SERVER_PORT", 18097),
AnalysisMode: strings.ToLower(vString(v, "ANALYSIS_MODE", "live")),
AuthEnabled: vBool(v, "ANALYSIS_AUTH_ENABLED", false),
DemoEnabled: vBool(v, "EBA_WORKER_DEMO_ENABLED", false),
LivePollMS: vInt(v, "EBA_WORKER_LIVE_POLL_MS", 1000),
DemoTickMS: vInt(v, "EBA_WORKER_TICK_MS", 400),
LiveMaxInflight: vInt(v, "EBA_WORKER_LIVE_MAX", 2),
DeerFlowBaseURL: strings.TrimRight(vString(v, "EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
DeerFlowUser: vString(v, "EBA_DF_USER", "admin"),
DeerFlowPassword: vString(v, "EBA_DF_PASSWORD", "123ewq"),
DeerFlowCoordinator: vString(v, "EBA_DF_COORDINATOR", "roundtable-coordinator"),
DeerFlowStreamIdleTimeout: vInt(v, "EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
LogLevel: vString(v, "LOG_LEVEL", "info"),
LogFile: vString(v, "LOG_FILE", ""),
LogMaxMB: vInt(v, "LOG_MAX_MB", 100),
LogMaxAgeDays: vInt(v, "LOG_MAX_AGE_DAYS", 14),
LogMaxBackups: vInt(v, "LOG_MAX_BACKUPS", 14),
SchemaPath: vString(v, "EBA_SCHEMA_PATH", "schema.sql"),
StrictConfig: vBool(v, "EBA_STRICT_CONFIG", false),
Version: vString(v, "EBA_VERSION", "dev"),
}
c.DBDSN = resolveDSN()
// live 默认关掉 demo;显式 ANALYSIS_MODE=demo 时打开假进度
c.DBDSN = resolveDSN(v)
if c.AnalysisMode == "demo" {
c.DemoEnabled = true
}
if c.AnalysisMode == "live" && !envExplicit("EBA_WORKER_DEMO_ENABLED") {
if c.AnalysisMode == "live" && !v.IsSet("EBA_WORKER_DEMO_ENABLED") {
c.DemoEnabled = false
}
if c.LiveMaxInflight < 1 {
c.LiveMaxInflight = 2
}
return c
}
func resolveDSN() string {
if d := strings.TrimSpace(os.Getenv("EBA_DB_DSN")); d != "" {
// Validate 启动前校验;返回警告列表。strict 时致命错误用 error 返回,调用方应退出进程。
func (c Config) Validate() (warnings []string, err error) {
switch c.AnalysisMode {
case "live", "demo":
default:
return nil, fmt.Errorf("ANALYSIS_MODE 只能是 live 或 demo,当前=%q", c.AnalysisMode)
}
if c.Port < 1 || c.Port > 65535 {
return nil, fmt.Errorf("SERVER_PORT 非法: %d", c.Port)
}
if c.DBDSN == "" {
return nil, fmt.Errorf("数据库 DSN 为空,请配置 EBA_DB_DSN 或 EBA_DB_*")
}
weakDF := c.DeerFlowPassword == "123ewq" || c.DeerFlowPassword == ""
weakHint := "正式环境请改 EBA_DF_PASSWORD,勿用联调默认口令"
// live 声明下就要管 DF 口令(即使误开了 demo 开关也不放过 strict)。
if c.AnalysisMode == "live" {
if !c.DemoEnabled && c.DeerFlowBaseURL == "" {
return nil, fmt.Errorf("live 模式必须配置 EBA_DF_BASE_URL")
}
if weakDF {
msg := "DeerFlow 仍使用联调默认/空密码:" + weakHint
if c.StrictConfig {
return nil, fmt.Errorf("%s(EBA_STRICT_CONFIG=true)", msg)
}
warnings = append(warnings, msg)
}
}
if !c.AuthEnabled {
warnings = append(warnings, "ANALYSIS_AUTH_ENABLED=false:写操作不强制账号头(仅联调)")
}
return warnings, nil
}
// newEnv 只绑定进程环境变量,不读配置文件,避免工作目录里偶然的 yaml 盖掉部署注入的 EBA_*。
func newEnv() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
return v
}
func resolveDSN(v *viper.Viper) string {
if d := strings.TrimSpace(v.GetString("EBA_DB_DSN")); v.IsSet("EBA_DB_DSN") && d != "" {
return ensureMultiStatements(d)
}
user := env("EBA_DB_USER", "eba")
pass := env("EBA_DB_PASSWORD", "eba_local_dev")
jdbc := strings.TrimSpace(os.Getenv("EBA_DB_URL"))
user := vString(v, "EBA_DB_USER", "eba")
pass := vString(v, "EBA_DB_PASSWORD", "eba_local_dev")
jdbc := ""
if v.IsSet("EBA_DB_URL") {
jdbc = strings.TrimSpace(v.GetString("EBA_DB_URL"))
}
if jdbc != "" {
if dsn, ok := jdbcToDSN(jdbc, user, pass); ok {
return dsn
}
}
host := env("EBA_DB_HOST", "127.0.0.1")
port := env("EBA_DB_PORT", "3306")
name := env("EBA_DB_NAME", "eightbf_analysis")
host := vString(v, "EBA_DB_HOST", "127.0.0.1")
port := vString(v, "EBA_DB_PORT", "3306")
name := vString(v, "EBA_DB_NAME", "eightbf_analysis")
return user + ":" + pass + "@tcp(" + host + ":" + port + ")/" + name +
"?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
}
// jdbcToDSN 把 Java 的 jdbc:mysql://host:port/db?... 转成 Go driver DSN。
func jdbcToDSN(jdbc, user, pass string) (string, bool) {
s := strings.TrimPrefix(jdbc, "jdbc:mysql://")
if s == jdbc {
return "", false
}
hostPath := s
query := ""
if i := strings.Index(s, "?"); i >= 0 {
hostPath = s[:i]
query = s[i+1:]
}
host := hostPath
db := "eightbf_analysis"
@@ -90,7 +171,6 @@ func jdbcToDSN(jdbc, user, pass string) (string, bool) {
db = hostPath[i+1:]
}
params := "parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
_ = query // Java JDBC 专用参数不照搬
return user + ":" + pass + "@tcp(" + host + ")/" + db + "?" + params, true
}
@@ -104,24 +184,23 @@ func ensureMultiStatements(dsn string) string {
return dsn + "?multiStatements=true"
}
func env(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}
func envExplicit(k string) bool {
_, ok := os.LookupEnv(k)
return ok
}
func envBool(k string, def bool) bool {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
// vString 未设置或空白都用缺省。Viper 对空环境变量会返回空串,这里与旧版 os.Getenv 行为对齐。
func vString(v *viper.Viper, key, def string) string {
if !v.IsSet(key) {
return def
}
switch strings.ToLower(v) {
s := strings.TrimSpace(v.GetString(key))
if s == "" {
return def
}
return s
}
func vBool(v *viper.Viper, key string, def bool) bool {
if !v.IsSet(key) {
return def
}
switch strings.ToLower(strings.TrimSpace(v.GetString(key))) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
@@ -131,13 +210,16 @@ func envBool(k string, def bool) bool {
}
}
func envInt(k string, def int) int {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
func vInt(v *viper.Viper, key string, def int) int {
if !v.IsSet(key) {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
s := strings.TrimSpace(v.GetString(key))
if s == "" {
return def
}
var n int
if _, err := fmt.Sscan(s, &n); err != nil {
return def
}
return n
+56
View File
@@ -0,0 +1,56 @@
package config
import "testing"
// 这些用例锁住「环境变量怎么解释」,避免换 Viper 后联调缺省或 demo 开关被悄悄改掉。
func TestLoadDefaults(t *testing.T) {
t.Setenv("ANALYSIS_MODE", "")
t.Setenv("EBA_WORKER_DEMO_ENABLED", "")
t.Setenv("SERVER_PORT", "")
// 空字符串按「没配」处理,应回到 live 且不跑 demo。
c := Load()
if c.AnalysisMode != "live" {
t.Fatalf("mode=%s", c.AnalysisMode)
}
if c.DemoEnabled {
t.Fatal("live 且未显式打开 demo 时不应跑假进度")
}
if c.Port != 18097 {
t.Fatalf("port=%d", c.Port)
}
}
func TestDemoModeForcesWorker(t *testing.T) {
t.Setenv("ANALYSIS_MODE", "demo")
t.Setenv("EBA_WORKER_DEMO_ENABLED", "false")
c := Load()
if !c.DemoEnabled {
t.Fatal("ANALYSIS_MODE=demo 必须开假进度,忽略 worker 开关")
}
}
func TestLiveExplicitDemo(t *testing.T) {
t.Setenv("ANALYSIS_MODE", "live")
t.Setenv("EBA_WORKER_DEMO_ENABLED", "yes")
c := Load()
if !c.DemoEnabled {
t.Fatal("live 下显式 EBA_WORKER_DEMO_ENABLED=yes 应保留假进度")
}
}
func TestStrictRejectsWeakPassword(t *testing.T) {
c := Config{
AnalysisMode: "live",
Port: 18097,
DBDSN: "x",
DeerFlowPassword: "123ewq",
DeerFlowBaseURL: "http://127.0.0.1:8001",
StrictConfig: true,
DemoEnabled: true,
}
_, err := c.Validate()
if err == nil {
t.Fatal("live + strict 即使同时开了 demo,也不能放过默认口令")
}
}
+3
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
)
// Auth 向 DeerFlow 换 Bearer,过期前复用。
@@ -68,5 +69,7 @@ func (a *Auth) BearerToken() (string, error) {
}
a.token = parsed.AccessToken
a.expiresAt = now.Add(time.Duration(parsed.ExpiresIn) * time.Second)
// 只打过期秒数,不打 token
logging.L.Infow("DeerFlow 登录成功", "expiresInSec", parsed.ExpiresIn)
return a.token, nil
}
+2 -2
View File
@@ -6,12 +6,12 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
)
// Client 调 DeerFlow:模型校验 / init / run/stream。
@@ -127,7 +127,7 @@ func (c *Client) InitMultiAgent(agentNames []string, mainAgent, model string) (m
if len(threadIDs) == 0 {
return nil, fmt.Errorf("多智能体初始化未返回 thread_ids")
}
log.Printf("DeerFlow init 完成 seats=%d", len(threadIDs))
logging.L.Infow("DeerFlow init 完成", "seats", len(threadIDs))
return threadIDs, nil
}
+28 -24
View File
@@ -4,31 +4,35 @@ import "time"
// AnalysisJob 一场会商作业(服务端「研讨录像带」)。
// JSON 字段名与前端 mapJob / Java 实体对齐,少做二次映射。
//
// plan 里约定的键:seatIndex(下一席)、manualIntervention、pendingInject(用户补充)、awaitReason。
// access 里约定的键:taskOwnerAccountIds、adminAccountIds、seatAccounts(席位 id → 账号列表)。
// 对话正文落在表的 chain_json,不是单独的 dialogues 列。
type AnalysisJob struct {
ID string `json:"id"`
RequestID string `json:"requestId,omitempty"`
TaskID string `json:"taskId,omitempty"`
DraftID string `json:"draftId,omitempty"`
DraftKey string `json:"draftKey,omitempty"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
Cycle int `json:"cycle"`
ActiveAgentID string `json:"activeAgentId,omitempty"`
ConsensusPercentage int `json:"consensusPercentage"`
PendingClarification string `json:"pendingClarification,omitempty"`
OrchestrationMode string `json:"orchestrationMode,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
OwnerAccountID string `json:"ownerAccountId,omitempty"`
Agents []map[string]interface{} `json:"agents"`
Dialogues []map[string]interface{} `json:"dialogues"`
ThreadIDs map[string]interface{} `json:"threadIds,omitempty"`
Plan map[string]interface{} `json:"plan"`
Access map[string]interface{} `json:"access"`
Progress map[string]interface{} `json:"progress"`
Result map[string]interface{} `json:"result,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
ID string `json:"id"`
RequestID string `json:"requestId,omitempty"`
TaskID string `json:"taskId,omitempty"`
DraftID string `json:"draftId,omitempty"`
DraftKey string `json:"draftKey,omitempty"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
Cycle int `json:"cycle"`
ActiveAgentID string `json:"activeAgentId,omitempty"`
ConsensusPercentage int `json:"consensusPercentage"`
PendingClarification string `json:"pendingClarification,omitempty"`
OrchestrationMode string `json:"orchestrationMode,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
OwnerAccountID string `json:"ownerAccountId,omitempty"`
Agents []map[string]interface{} `json:"agents"`
Dialogues []map[string]interface{} `json:"dialogues"`
ThreadIDs map[string]interface{} `json:"threadIds,omitempty"`
Plan map[string]interface{} `json:"plan"`
Access map[string]interface{} `json:"access"`
Progress map[string]interface{} `json:"progress"`
Result map[string]interface{} `json:"result,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// EnsurePlan plan 为空时补可写 Map。
+99
View File
@@ -0,0 +1,99 @@
// Package logging 提供进程级日志。
//
// 为什么用 zap:Gin 服务里最常见的结构化日志,字段稳定,方便按 jobId / reqId 检索。
// 约定:业务代码走 SugaredLogger 的 *w 方法(Infow/Warnw/Errorw),键名用本文件常量,不要自造别名。
// 边界:不写 token、密码、完整对话。失败详情在调用方截断后再传入。
package logging
import (
"os"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
// Options 日志初始化选项(对齐设计要求 §11)。
type Options struct {
Level string // debug|info|warn|error
File string // 非空则同时写滚动文件
MaxMB int
MaxAge int // 天
MaxBak int
}
// L 全局日志。Setup 之前是空实现,避免测试或提前打日志时空指针。
var L = zap.NewNop().Sugar()
// Setup 配置全局 zap:默认 stdout;LOG_FILE 非空时再加滚动文件。返回关闭函数(刷盘)。
//
// 用控制台编码器而不是 JSON:现有采集按 `jobId=` 这种键值对 grep,换 JSON 会让运维脚本对不上。
func Setup(opt Options) (closeFn func()) {
level := parseLevel(opt.Level)
encCfg := zap.NewDevelopmentEncoderConfig()
encCfg.EncodeTime = zapcore.ISO8601TimeEncoder
encCfg.EncodeLevel = zapcore.CapitalLevelEncoder
encCfg.EncodeCaller = zapcore.ShortCallerEncoder
encoder := zapcore.NewConsoleEncoder(encCfg)
writers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
closeFn = func() { _ = L.Sync() }
if path := strings.TrimSpace(opt.File); path != "" {
if opt.MaxMB <= 0 {
opt.MaxMB = 100
}
if opt.MaxAge <= 0 {
opt.MaxAge = 14
}
if opt.MaxBak <= 0 {
opt.MaxBak = 14
}
lj := &lumberjack.Logger{
Filename: path,
MaxSize: opt.MaxMB,
MaxAge: opt.MaxAge,
MaxBackups: opt.MaxBak,
LocalTime: true,
Compress: false,
}
writers = append(writers, zapcore.AddSync(lj))
prev := closeFn
closeFn = func() {
prev()
_ = lj.Close()
}
}
core := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(writers...), level)
logger := zap.New(core, zap.AddCaller())
zap.ReplaceGlobals(logger)
L = logger.Sugar()
return closeFn
}
func parseLevel(s string) zapcore.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return zapcore.DebugLevel
case "warn", "warning":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
// 常用键名,方便 grep / 采集。调用方不要改成 job_id 这类别名。
const (
KeyJobID = "jobId"
KeyRequestID = "requestId"
KeyTaskID = "taskId"
KeyAccountID = "accountId"
KeyStatus = "status"
KeyPhase = "phase"
KeyModel = "model"
KeyReqID = "reqId"
)
+33 -39
View File
@@ -5,15 +5,21 @@ import (
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
// JobRepo 会商作业表读写(eba_analysis_job)。大字段整段 JSON 文本存。
// JobRepo 会商作业表读写(eba_analysis_job)。大字段整段 JSON 文本存,不拆列。
//
// 为什么用 sqlx:在标准库连接上多了按列名扫描,SELECT * 换列顺序也不会对错字段。
// 约定:SQL 与 Java 表结构对齐;空字符串写入 NULL,读回来再变成空串。
// 边界:本仓库没有领取锁。两个进程同时扫 queued 会抢同一行。
type JobRepo struct {
DB *sql.DB
DB *sqlx.DB
}
func NewJobRepo(db *sql.DB) *JobRepo {
func NewJobRepo(db *sqlx.DB) *JobRepo {
return &JobRepo{DB: db}
}
@@ -67,51 +73,54 @@ func (r *JobRepo) UpdateProgress(job *domain.AnalysisJob) error {
}
func (r *JobRepo) FindByID(id string) (*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE id=?`, id)
rows, err := r.DB.Queryx(`SELECT * FROM eba_analysis_job WHERE id=?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
list, err := scanJobs(rows)
if err != nil || len(list) == 0 {
return nil, err
}
return scanJob(rows)
return list[0], nil
}
func (r *JobRepo) FindActiveByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? AND status IN (?,?,?)
rows, err := r.DB.Queryx(`SELECT * FROM eba_analysis_job WHERE task_id=? AND status IN (?,?,?)
ORDER BY created_at DESC LIMIT 1`,
taskID, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
list, err := scanJobs(rows)
if err != nil || len(list) == 0 {
return nil, err
}
return scanJob(rows)
return list[0], nil
}
func (r *JobRepo) FindLatestByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? ORDER BY created_at DESC LIMIT 1`, taskID)
rows, err := r.DB.Queryx(`SELECT * FROM eba_analysis_job WHERE task_id=? ORDER BY created_at DESC LIMIT 1`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
list, err := scanJobs(rows)
if err != nil || len(list) == 0 {
return nil, err
}
return scanJob(rows)
return list[0], nil
}
func (r *JobRepo) FindActiveJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status IN (?,?,?) ORDER BY created_at ASC`,
rows, err := r.DB.Queryx(`SELECT * FROM eba_analysis_job WHERE status IN (?,?,?) ORDER BY created_at ASC`,
domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
@@ -121,7 +130,7 @@ func (r *JobRepo) FindActiveJobs() ([]*domain.AnalysisJob, error) {
}
func (r *JobRepo) FindQueuedJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status=? ORDER BY created_at ASC`, domain.StatusQueued)
rows, err := r.DB.Queryx(`SELECT * FROM eba_analysis_job WHERE status=? ORDER BY created_at ASC`, domain.StatusQueued)
if err != nil {
return nil, err
}
@@ -151,35 +160,20 @@ func (r *JobRepo) IsCancelFlag(id string) (bool, error) {
return v.Valid && v.Int64 == 1, nil
}
func scanJobs(rows *sql.Rows) ([]*domain.AnalysisJob, error) {
func scanJobs(rows *sqlx.Rows) ([]*domain.AnalysisJob, error) {
var out []*domain.AnalysisJob
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
m := map[string]interface{}{}
if err := rows.MapScan(m); err != nil {
return nil, err
}
out = append(out, j)
out = append(out, jobFromMap(m))
}
return out, rows.Err()
}
func scanJob(rows *sql.Rows) (*domain.AnalysisJob, error) {
cols, err := rows.Columns()
if err != nil {
return nil, err
}
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
m := map[string]interface{}{}
for i, c := range cols {
m[c] = vals[i]
}
// jobFromMap 按列名组装作业。对话正文存在 chain_json,这是和 Java 表对齐的历史列名,不是链条配置。
func jobFromMap(m map[string]interface{}) *domain.AnalysisJob {
j := &domain.AnalysisJob{}
j.ID = asString(m["id"])
j.RequestID = asString(m["request_id"])
@@ -209,7 +203,7 @@ func scanJob(rows *sql.Rows) (*domain.AnalysisJob, error) {
if t := asTime(m["updated_at"]); t != nil {
j.UpdatedAt = t
}
return j, nil
return j
}
func mustJSON(v interface{}) interface{} {
+10 -2
View File
@@ -1,8 +1,9 @@
package repo
import (
"log"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
)
// ApplySchema 按语句拆开执行;重复索引等可忽略错误不阻断启动。
@@ -15,11 +16,18 @@ func (r *JobRepo) ApplySchema(schemaSQL string) {
strings.Contains(msg, "already exists") {
continue
}
log.Printf("schema 语句失败(已跳过): %v | sql=%.80s…", err, stmt)
logging.L.Warnw("schema 语句失败(已跳过)", "err", err, "sql", trunc(stmt, 80))
}
}
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func splitSQL(s string) []string {
var out []string
var b strings.Builder
+8
View File
@@ -33,6 +33,11 @@ type AccessService struct {
AuthEnabled bool
}
// CanMutate 谁可以续跑、改计划、注入。
//
// 鉴权关闭时一律放行(联调)。打开后:没账号拒绝;管理员、任务责任人角色、作业拥有者、
// access.taskOwnerAccountIds / adminAccountIds 里的账号可以改。
// 岗位责任人只有绑了席位才能改本场,取消权另见 CanCancel。
func (a *AccessService) CanMutate(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
@@ -52,6 +57,8 @@ func (a *AccessService) CanMutate(job *domain.AnalysisJob, c Caller) bool {
return c.Role == RolePositionOwner && len(a.boundSeats(job, c.AccountID)) > 0
}
// CanCancel 谁可以取消。岗位责任人默认不能取消,除非他同时是作业拥有者或名单里的任务责任人。
// 这条比 CanMutate 更严:席位上的人可以插话,但不能把整场停掉。
func (a *AccessService) CanCancel(job *domain.AnalysisJob, c Caller) bool {
if c.Role == RolePositionOwner {
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) || a.isListedTaskOwner(job, c.AccountID) {
@@ -74,6 +81,7 @@ func (a *AccessService) CanCancel(job *domain.AnalysisJob, c Caller) bool {
return a.isListedTaskOwner(job, c.AccountID)
}
// CanRead 谁可以看。能改或能取消的人可以看;只绑了席位、没有写权限的账号也可以看(随后会被裁剪)。
func (a *AccessService) CanRead(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
+3 -3
View File
@@ -1,17 +1,17 @@
package service
import (
"log"
"strconv"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// DemoWorker 假进度推进(仅 ANALYSIS_MODE=demo)。
type DemoWorker struct {
Repo *repo.JobRepo
Repo *repo.JobRepo
Every time.Duration
}
@@ -40,7 +40,7 @@ func (w *DemoWorker) Start() {
}
for _, job := range list {
if err := w.advance(job); err != nil {
log.Printf("demo worker advance failed jobId=%s: %v", job.ID, err)
logging.L.Warnw("演示推进失败", logging.KeyJobID, job.ID, "err", err)
job.Status = domain.StatusError
job.Phase = "error"
job.Error = err.Error()
+29 -14
View File
@@ -33,7 +33,7 @@ type ResumeJobRequest struct {
}
type PatchJobPlanRequest struct {
ManualIntervention *bool `json:"manualIntervention"`
ManualIntervention *bool `json:"manualIntervention"`
Model *string `json:"model"`
}
@@ -47,6 +47,11 @@ type JobService struct {
Access *AccessService
}
// Create 落一场排队作业,不在这里调模型。Worker 下一拍才会领走。
//
// 约定:没传 manualIntervention 时默认开人工卡点;编排模式缺省 roundtable。
// 席位为空时塞两个联调桩,避免前端漏传 agents 时直接起不来。
// 拥有者优先用 body,没有再用请求头里的账号。
func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.AnalysisJob, error) {
if req == nil {
req = &CreateJobRequest{}
@@ -64,19 +69,19 @@ func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.Analy
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),
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,
@@ -131,6 +136,8 @@ func (s *JobService) GetByTaskID(taskID string, latestOnly bool, caller Caller)
return s.Access.ProjectForCaller(job, caller), nil
}
// Cancel 给还在排队、运行、等人中的作业打 cancel_flag,并把状态写成 cancelled。
// 已经结束的作业再点取消:不报错,原样返回(前端可以重复点)。
func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
@@ -154,6 +161,8 @@ func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, erro
return s.Access.ProjectForCaller(job, caller), nil
}
// Resume 只接受 awaiting_input。回答会写进用户气泡,并拼进 plan.pendingInject,下一轮派活时带上。
// 然后状态回到 queued,等 Worker 重新领走。其它状态拒绝,避免把跑到一半的场子重置。
func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
@@ -195,6 +204,10 @@ func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*
return s.Access.ProjectForCaller(fresh, caller), nil
}
// PatchPlan 改模型或人工干预开关。作业已结束则拒绝。
//
// 关掉人工干预、且当前正停在 manual_continue 时,顺手放行:清掉等待原因并重新排队。
// 这样页面上关开关后不用再点一次「继续」。
func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
@@ -253,6 +266,8 @@ func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Call
return s.Access.ProjectForCaller(fresh, caller), nil
}
// Inject 在会商进行中插入用户补充。空文本拒绝;已结束拒绝。
// 正文进对话气泡,同时累加到 plan.pendingInject,下一轮才喂给模型,不打断当前席。
func (s *JobService) Inject(id string, body *InjectJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
+37 -8
View File
@@ -2,7 +2,6 @@ package worker
import (
"fmt"
"log"
"strings"
"sync"
"time"
@@ -10,24 +9,30 @@ import (
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveChainRunner 真模型按链条顺序执行(被 LiveWorker 异步调用)。
type LiveChainRunner struct {
DF *deerflow.Client
Repo *repo.JobRepo
DF *deerflow.Client
Repo *repo.JobRepo
Coord string
mu sync.Mutex
lastFlush time.Time
mu sync.Mutex
lastFlush time.Time
}
// Run 跑完一场真模型会商。调用方已经把状态改成 running,这里不再抢队列。
//
// 顺序:席位不够直接失败 → 校验模型(禁止网关静默换模)→ 没有 thread 就 init →
// 总控派活、席位发言,人工干预开着则每席结束后暂停 → 收口写成 done。
// 边界:任一步 panic 记失败,不当成功;取消看库里的 cancel_flag,不靠内存标记。
func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
jobID := job.ID
defer func() {
if rec := recover(); rec != nil {
log.Printf("真模型会商 panic jobId=%s: %v", jobID, rec)
logging.L.Errorw("真模型会商 panic", logging.KeyJobID, jobID, "err", rec)
r.fail(job, fmt.Sprint(rec))
}
}()
@@ -285,9 +290,15 @@ func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
plan["awaitReason"] = nil
job.Plan = plan
r.flush(job)
log.Printf("真模型会商完成 jobId=%s", jobID)
logging.L.Infow("真模型会商完成",
logging.KeyJobID, jobID,
logging.KeyStatus, domain.StatusDone,
logging.KeyModel, job.Model,
)
}
// pauseForInput 把作业停在 awaiting_input。seatIndex 记下一席,等人或等确认后再从这里继续。
// reason 只有两类约定:澄清(用户要补一句)和 manual_continue(人工确认再往下走)。
func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string]interface{}, next int, reason, detail string) {
plan["seatIndex"] = next
plan["awaitReason"] = reason
@@ -301,7 +312,11 @@ func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string
}
putProgress(job, job.ConsensusPercentage, msg)
r.flush(job)
log.Printf("作业暂停等待输入 jobId=%s reason=%s", job.ID, reason)
logging.L.Infow("作业暂停等待输入",
logging.KeyJobID, job.ID,
logging.KeyStatus, domain.StatusAwaitingInput,
logging.KeyPhase, reason,
)
}
func (r *LiveChainRunner) deltaHandler(job *domain.AnalysisJob, bubbleID string) deerflow.TextDeltaHandler {
@@ -326,6 +341,8 @@ func (r *LiveChainRunner) flush(job *domain.AnalysisJob) {
r.flushLocked(job)
}
// flushLocked 把内存里的作业写回表。写之前从库里拉模型、人工开关和尚未消费的注入,
// 这样页面上改设置不会被本轮旧内存盖掉。调用方必须已持有 r.mu。
func (r *LiveChainRunner) flushLocked(job *domain.AnalysisJob) {
plan := ensurePlan(job)
r.pullManualFromDB(job, plan)
@@ -353,6 +370,7 @@ func (r *LiveChainRunner) markCancelled(job *domain.AnalysisJob) {
job.Error = "cancelled"
putProgress(job, job.ConsensusPercentage, "cancelled")
r.flush(job)
logging.L.Infow("真模型会商已取消", logging.KeyJobID, job.ID, logging.KeyStatus, domain.StatusCancelled)
}
func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
@@ -367,6 +385,17 @@ func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
}
putProgress(job, 0, "error")
r.flush(job)
// 详情截断,避免把整段模型正文打进日志
detail := message
if rs := []rune(detail); len(rs) > 200 {
detail = string(rs[:200]) + "…"
}
logging.L.Warnw("真模型会商失败",
logging.KeyJobID, job.ID,
logging.KeyStatus, domain.StatusError,
logging.KeyModel, job.Model,
"detail", detail,
)
}
func (r *LiveChainRunner) pullModelFromDB(job *domain.AnalysisJob) {
+22 -11
View File
@@ -1,20 +1,21 @@
package worker
import (
"log"
"sync/atomic"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/logging"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveWorker 定时扫 queued,最多并发 2 场交给 LiveChainRunner。
// LiveWorker 定时扫 queued,最多并发 MaxInflight 场交给 LiveChainRunner。
type LiveWorker struct {
Repo *repo.JobRepo
Runner *LiveChainRunner
PollEvery time.Duration
inflight int32
Repo *repo.JobRepo
Runner *LiveChainRunner
PollEvery time.Duration
MaxInflight int32
inflight int32
}
func (w *LiveWorker) Start() {
@@ -31,22 +32,26 @@ func (w *LiveWorker) Start() {
}
func (w *LiveWorker) poll() {
if atomic.LoadInt32(&w.inflight) >= 2 {
max := w.MaxInflight
if max < 1 {
max = 2
}
if atomic.LoadInt32(&w.inflight) >= max {
return
}
queued, err := w.Repo.FindQueuedJobs()
if err != nil {
log.Printf("LiveWorker 扫队列失败: %v", err)
logging.L.Warnw("LiveWorker 扫队列失败", "err", err)
return
}
for _, job := range queued {
if atomic.LoadInt32(&w.inflight) >= 2 {
if atomic.LoadInt32(&w.inflight) >= max {
break
}
job.Status = domain.StatusRunning
job.Phase = "claimed"
if err := w.Repo.UpdateProgress(job); err != nil {
log.Printf("LiveWorker 占坑失败 jobId=%s: %v", job.ID, err)
logging.L.Warnw("LiveWorker 占坑失败", logging.KeyJobID, job.ID, "err", err)
continue
}
atomic.AddInt32(&w.inflight, 1)
@@ -57,7 +62,13 @@ func (w *LiveWorker) poll() {
if err != nil || fresh == nil {
return
}
log.Printf("开始真模型会商 jobId=%s", jobID)
logging.L.Infow("开始真模型会商",
logging.KeyJobID, jobID,
logging.KeyRequestID, fresh.RequestID,
logging.KeyTaskID, fresh.TaskID,
logging.KeyModel, fresh.Model,
logging.KeyStatus, fresh.Status,
)
w.Runner.Run(fresh)
}()
}