feat: 换到 Gin 主流栈并补齐运维能力
HTTP 用 Gin,配置用 Viper,日志用 zap,数据用 sqlx;保留原有接口与会商行为。 顺带补上就绪探活、请求号、滚动日志、systemd 与 Dockerfile。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0c45b86d67
commit
bd427b57a1
26 files changed
+1213
-302
No files matched your search
+193
-80
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user