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

+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
}