初始化

This commit is contained in:
SunYF
2026-08-28 14:07:22 +08:00
parent a185247ab1
commit b34325a16f
971 changed files with 291 additions and 220360 deletions
@@ -1,75 +0,0 @@
package handler
import (
"net/http"
"strings"
"time"
"bj_power_workstation/internal/auth"
"bj_power_workstation/internal/svc"
"golang.org/x/crypto/bcrypt"
)
func healthHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := ctx.Store.Ping(); err == nil {
ok(w, map[string]any{
"status": "UP",
"time": time.Now().Format("2006-01-02 15:04:05"),
"service": "bj_power_workstation",
})
return
}
fail(w, http.StatusServiceUnavailable, "本地数据库不可用")
}
}
// loginHandler 本地 SQLite + bcrypt 校验登录,返回 {token, expireAt, user}。
func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if strings.TrimSpace(req.Username) == "" || req.Password == "" {
fail(w, http.StatusBadRequest, "用户名和密码必填")
return
}
u, err := ctx.Store.GetUserByUsername(strings.TrimSpace(req.Username))
if err != nil {
fail(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(req.Password)) != nil {
fail(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
token, expireAt, err := auth.SignToken(
ctx.Config.Auth.AccessSecret,
ctx.Config.Auth.AccessExpire,
auth.TokenInfo{UserID: u.ID, Username: u.Username, RealName: u.RealName, Role: u.Role},
)
if err != nil {
fail(w, http.StatusInternalServerError, "签发 token 失败")
return
}
ok(w, map[string]any{
"token": token,
"expireAt": expireAt,
"user": map[string]any{
"id": u.ID,
"username": u.Username,
"realName": u.RealName,
"role": u.Role,
},
})
}
}
@@ -1,24 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
)
// parseJSON 解析请求体 JSON,失败返回错误(风格仿 bj_power_wms/internal/handler/common.go
func parseJSON(r *http.Request, v any) error {
dec := json.NewDecoder(r.Body)
return dec.Decode(v)
}
func atoi(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
@@ -1,103 +0,0 @@
package handler
import (
"net/http"
"net/url"
"strconv"
"strings"
"bj_power_workstation/internal/mes"
)
// taskCurrentHandler 代理 MES GET /api/internal/station/task?dockCode=。
// 响应体原样透传(MES 已按 {code,message,data} 封装,这里不再二次包装,避免双重嵌套);
// MES 不可达时返回 502 JSON 错误。
func taskCurrentHandler(mesc *mes.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dockCode := strings.TrimSpace(r.URL.Query().Get("dockCode"))
if dockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode 参数")
return
}
body, _, err := mesc.Get("/api/internal/station/task", url.Values{"dockCode": {dockCode}})
if err != nil {
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
// ---------- 工艺 PDF 直通代理 ----------
//
// 二选一说明:本工程采用「本地无鉴权前缀 /pdfopen/<name>」方案——iframe/img/window.open
// 无法方便地携带 Authorization 头,因此对 /pdfopen/ 路径免 JWT 并解析路径参数作为文件名,
// 内部仍走 MES /api/internal/files/<name>(内部 API 由 X-API-TOKEN 保护)。
// 同时保留需登录态的 GET /api/pdf?name=<file> 供程序化调用。
// 两端点均把文件流原样转发并透传 Content-Type/Content-Disposition,不套 {code,message,data}。
// pdfQueryHandler GET /api/pdf?name=<file>(需登录)
func pdfQueryHandler(mesc *mes.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
forwardFile(w, mesc, strings.TrimSpace(r.URL.Query().Get("name")))
}
}
// pdfOpenHandler GET /pdfopen/<name>(免鉴权,供 iframe 直链)
func pdfOpenHandler(mesc *mes.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// go-zero 路由参数也可用 PathVar,此处直接解析 URL 以减少对外部 API 的依赖
raw := strings.TrimPrefix(r.URL.EscapedPath(), "/pdfopen/")
name, err := url.PathUnescape(raw)
if err != nil {
fail(w, http.StatusBadRequest, "非法文件名")
return
}
forwardFile(w, mesc, name)
}
}
// forwardFile 校验单段文件名后拉取 MES 文件流并原样转发。
func forwardFile(w http.ResponseWriter, mesc *mes.Client, name string) {
name = strings.TrimSpace(name)
if name == "" {
fail(w, http.StatusBadRequest, "缺少文件名")
return
}
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." || strings.Contains(name, "..") {
fail(w, http.StatusBadRequest, "非法文件名")
return
}
body, header, err := mesc.Get("/api/internal/files/"+url.PathEscape(name), nil)
if err != nil {
fail(w, http.StatusBadGateway, "工艺文件获取失败:"+errString(err))
return
}
ct := header.Get("Content-Type")
if ct == "" {
ct = "application/pdf"
}
w.Header().Set("Content-Type", ct)
if cd := header.Get("Content-Disposition"); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
w.Header().Set("X-Original-Filename", strconv.Quote(name))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// errString 压缩错误信息长度便于前端展示。
func errString(err error) string {
s := strings.TrimSpace(err.Error())
s = strings.ReplaceAll(s, "\n", " ")
const maxLen = 180
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
@@ -1,104 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"bj_power_workstation/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
// processDoneHandler POST /api/report/process-done
// {dockCode, orderNo, sn, processCode, operator}
// 流程:先冲刷一轮积压 → 实时报 MES /api/internal/station/done
// MES 不可达则写入 report_queue(kind=process_done) 并返回 data.queued=true(离线缓存稍后自动重传)。
func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DockCode string `json:"dockCode"`
OrderNo string `json:"orderNo"`
Sn string `json:"sn"`
ProcessCode string `json:"processCode"`
Operator string `json:"operator"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.DockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode")
return
}
// 先尽力冲刷历史积压,失败不影响本次上报
ctx.Syncer.RunOnce(r.Context())
body := map[string]any{
"dockCode": req.DockCode,
"orderNo": req.OrderNo,
"sn": req.Sn,
"processCode": req.ProcessCode,
"operator": req.Operator,
}
payloadBytes, _ := json.Marshal(body)
if _, _, err := ctx.Mes.Post("/api/internal/station/done", json.RawMessage(payloadBytes)); err != nil {
if qerr := ctx.Store.InsertQueueItem("process_done", string(payloadBytes)); qerr != nil {
fail(w, http.StatusInternalServerError, "上报失败且离线队列写入失败:"+qerr.Error())
return
}
logx.Errorf("工序完成上报失败已离线排队: %v", err)
ok(w, map[string]any{"queued": true})
return
}
ok(w, map[string]any{"queued": false})
}
}
// tempStoreHandler POST /api/report/temp-store
// {dockCode, orderNo, sn, operator} → 调 MES /api/internal/station/checkin
// body 附带 type=temp_store;不可达时以同样 payload 写入 report_queue(kind=temp_store)。
func tempStoreHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DockCode string `json:"dockCode"`
OrderNo string `json:"orderNo"`
Sn string `json:"sn"`
Operator string `json:"operator"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.DockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode")
return
}
// 先尽力冲刷历史积压,失败不影响本次上报
ctx.Syncer.RunOnce(r.Context())
body := map[string]any{
"type": "temp_store", // checkin 类型标识:暂存退库(随 payload 一并透传重传)
"dockCode": req.DockCode,
"orderNo": req.OrderNo,
"sn": req.Sn,
"operator": req.Operator,
}
payloadBytes, _ := json.Marshal(body)
if _, _, err := ctx.Mes.Post("/api/internal/station/checkin", json.RawMessage(payloadBytes)); err != nil {
if qerr := ctx.Store.InsertQueueItem("temp_store", string(payloadBytes)); qerr != nil {
fail(w, http.StatusInternalServerError, "上报失败且离线队列写入失败:"+qerr.Error())
return
}
logx.Errorf("暂存退库上报失败已离线排队: %v", err)
ok(w, map[string]any{"queued": true})
return
}
ok(w, map[string]any{"queued": false})
}
}
@@ -1,26 +0,0 @@
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 统一响应结构:{ code, message, data }(风格仿 bj_power_wms/internal/handler/response.go
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func ok(w http.ResponseWriter, data any) {
httpx.WriteJson(w, http.StatusOK, Response{Code: 0, Message: "ok", Data: data})
}
func fail(w http.ResponseWriter, httpStatus int, message string) {
httpx.WriteJson(w, httpStatus, Response{Code: -1, Message: message})
}
func failCode(w http.ResponseWriter, httpStatus, code int, message string) {
httpx.WriteJson(w, httpStatus, Response{Code: code, Message: message})
}
@@ -1,40 +0,0 @@
package handler
import (
"net/http"
"bj_power_workstation/internal/auth"
"bj_power_workstation/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
// 免鉴权:健康检查 / 登录
server.AddRoutes(
[]rest.Route{
{Method: http.MethodGet, Path: "/api/health", Handler: healthHandler(ctx)},
{Method: http.MethodPost, Path: "/api/auth/login", Handler: loginHandler(ctx)},
// 前端静态托管(auth.Interceptor 对非 /api/、/pdfopen/ 路径直接放行)
{Method: http.MethodGet, Path: "/", Handler: indexHandler(ctx.WebFS)},
{Method: http.MethodGet, Path: "/assets/:file", Handler: assetHandler(ctx.WebFS)},
},
)
// 登录鉴权中间件:白名单 /api/health、/api/auth/login、/pdfopen/*;其余 /api/* 需 JWT
server.Use(auth.Interceptor(ctx.Config.Auth.AccessSecret, ctx.Config.Auth.AccessExpire))
// 业务接口(均需登录 JWT,响应统一 {code,message,data}PDF 代理除外——原样转发文件流)
server.AddRoutes(
[]rest.Route{
{Method: http.MethodGet, Path: "/api/task/current", Handler: taskCurrentHandler(ctx.Mes)},
{Method: http.MethodGet, Path: "/api/pdf", Handler: pdfQueryHandler(ctx.Mes)},
{Method: http.MethodGet, Path: "/pdfopen/:name", Handler: pdfOpenHandler(ctx.Mes)},
{Method: http.MethodGet, Path: "/api/tightening/list", Handler: tighteningListHandler(ctx)},
{Method: http.MethodPost, Path: "/api/report/process-done", Handler: processDoneHandler(ctx)},
{Method: http.MethodPost, Path: "/api/report/temp-store", Handler: tempStoreHandler(ctx)},
{Method: http.MethodPost, Path: "/api/sync/flush", Handler: syncFlushHandler(ctx)},
{Method: http.MethodGet, Path: "/api/sync/stats", Handler: syncStatsHandler(ctx)},
},
)
}
@@ -1,69 +0,0 @@
package handler
import (
"io/fs"
"net/http"
"path"
"strings"
)
// indexHandler 服务嵌入式前端首页 web/static/index.html。
func indexHandler(fsys fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
b, err := fs.ReadFile(fsys, "index.html")
if err != nil {
http.Error(w, "前端页面尚未构建:请在 frontend 目录执行 pnpm build", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
}
}
// mimeFallback 构建产物常见静态资源的 Content-Type 兜底表
// mime.TypeByExtension 在部分 Windows 环境依赖系统注册表,结果不稳定)。
var mimeFallback = map[string]string{
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".webp": "image/webp",
}
// assetHandler 服务 vite 构建产物 /assets/<file>。
// 前端使用 hash 路由,静态托管只需覆盖根路径与扁平的 assets 目录。
func assetHandler(fsys fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.EscapedPath(), "/assets/")
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
http.NotFound(w, r)
return
}
if _, err := fs.Stat(fsys, "assets/"+name); err != nil {
http.NotFound(w, r)
return
}
ct := mimeFallback[strings.ToLower(path.Ext(name))]
if ct == "" {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") // vite 产物带内容哈希
b, err := fs.ReadFile(fsys, "assets/"+name)
if err != nil {
return
}
_, _ = w.Write(b)
}
}
@@ -1,34 +0,0 @@
package handler
import (
"net/http"
"bj_power_workstation/internal/svc"
)
// syncFlushHandler POST /api/sync/flush 手动立即执行一轮同步,返回本次成功条数。
func syncFlushHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
count := ctx.Syncer.RunOnce(r.Context())
ok(w, map[string]any{
"count": count,
"message": "本轮同步完成",
})
}
}
// syncStatsHandler GET /api/sync/stats 返回待同步条数统计。
func syncStatsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pendingTorque, err1 := ctx.Store.PendingTorqueCount()
pendingQueue, err2 := ctx.Store.PendingQueueCount()
if err1 != nil || err2 != nil {
fail(w, http.StatusInternalServerError, "查询待同步统计失败")
return
}
ok(w, map[string]any{
"pendingTorque": pendingTorque,
"pendingQueue": pendingQueue,
})
}
}
@@ -1,37 +0,0 @@
package handler
import (
"net/http"
"strconv"
"bj_power_workstation/internal/svc"
)
// tighteningListHandler GET /api/tightening/list?sn=&only_pending=true&limit=100
// 查询本地 torque_results(最新优先)。
func tighteningListHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
sn := q.Get("sn")
onlyPending := false
if v, err := strconv.ParseBool(q.Get("only_pending")); err == nil {
onlyPending = v
}
limit := atoi(q.Get("limit"), 100)
if limit <= 0 || limit > 500 {
limit = 500
}
list, err := ctx.Store.ListTorqueResults(sn, onlyPending, limit)
if err != nil {
fail(w, http.StatusInternalServerError, "查询拧紧结果失败:"+err.Error())
return
}
ok(w, map[string]any{
"total": len(list),
"list": list,
})
}
}