本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"bj_power_workstation/internal/mes"
|
||
"bj_power_workstation/internal/pdfcache"
|
||
"bj_power_workstation/internal/svc"
|
||
)
|
||
|
||
// taskCurrentHandler 代理 MES GET /api/internal/station/task?stationNo=。
|
||
// 响应体原样透传(MES 已按 {code,message,data} 封装,不再二次包装)。
|
||
func taskCurrentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
stationNo := atoi(strings.TrimSpace(r.URL.Query().Get("stationNo")), 0)
|
||
// 0=上线位为合法虚拟工位,仅负数视为未配置
|
||
if stationNo < 0 {
|
||
fail(w, http.StatusBadRequest, "缺少 stationNo 参数")
|
||
return
|
||
}
|
||
|
||
body, _, err := ctx.Mes.Get("/api/internal/station/task", url.Values{"stationNo": {strconv.Itoa(stationNo)}})
|
||
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)
|
||
}
|
||
}
|
||
|
||
// workloadHandler 代理 MES GET /api/internal/station/workload?operator=&stationNo=&from=&to=(块6)。
|
||
// 工位终端「我的工作量」查询当前登录人全部工位的工作,stationNo 为空=全部工位;MES 响应体原样透传。
|
||
func workloadHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
operator := strings.TrimSpace(r.URL.Query().Get("operator"))
|
||
stationNo := strings.TrimSpace(r.URL.Query().Get("stationNo"))
|
||
from := strings.TrimSpace(r.URL.Query().Get("from"))
|
||
to := strings.TrimSpace(r.URL.Query().Get("to"))
|
||
params := url.Values{}
|
||
if operator != "" {
|
||
params.Set("operator", operator)
|
||
}
|
||
if stationNo != "" {
|
||
params.Set("stationNo", stationNo)
|
||
}
|
||
if from != "" {
|
||
params.Set("from", from)
|
||
}
|
||
if to != "" {
|
||
params.Set("to", to)
|
||
}
|
||
body, _, err := ctx.Mes.Get("/api/internal/station/workload", params)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// stationsHandler 代理 MES GET /stations(工位数以数据库为准,不写死 12),原样透传响应体。
|
||
func stationsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
body, _, err := ctx.Mes.Get("/stations", nil)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// ---------- 工位停单/恢复接单(透传 MES 内部 API) ----------
|
||
|
||
// stationPauseHandler POST /api/station/pause —— 工人停止接单/恢复接单。
|
||
// 请求体按定稿契约:{stationNo:number, paused:bool, reason:string};
|
||
// operator 由本服务补当前登录人(MES 记录操作人,body 多余字段 MES 忽略),响应原样透传。
|
||
func stationPauseHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
payload, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "请求体读取失败")
|
||
return
|
||
}
|
||
var probe struct {
|
||
StationNo int `json:"stationNo"`
|
||
Paused bool `json:"paused"`
|
||
}
|
||
_ = json.Unmarshal(payload, &probe)
|
||
if probe.StationNo <= 0 {
|
||
fail(w, http.StatusBadRequest, "缺少 stationNo 参数")
|
||
return
|
||
}
|
||
var req map[string]any
|
||
_ = json.Unmarshal(payload, &req)
|
||
injectOperator(req, r)
|
||
body, _ := json.Marshal(req)
|
||
resp, _, perr := ctx.Mes.Post("/api/internal/station/pause", json.RawMessage(body))
|
||
mesJSON(w, resp, perr)
|
||
}
|
||
}
|
||
|
||
// stationStateHandler GET /api/station/state?date=yyyy-MM-dd —— 当天各工位停单状态。
|
||
// MES 响应 [{stationNo,paused,reason,operator}],原样透传;date 缺省由 MES 取当天。
|
||
func stationStateHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
var params url.Values
|
||
if d := strings.TrimSpace(r.URL.Query().Get("date")); d != "" {
|
||
params = url.Values{"date": {d}}
|
||
}
|
||
body, _, err := ctx.Mes.Get("/api/internal/station/state", params)
|
||
mesJSON(w, body, err)
|
||
}
|
||
}
|
||
|
||
// ---------- 工艺 PDF 直通代理(含本地预缓存) ----------
|
||
//
|
||
// 前端 iframe/window.open 无法携带 Authorization,故提供免 JWT 前缀 /pdfopen/<name>,
|
||
// 内部仍走 MES /api/internal/files/<name>(由 X-API-TOKEN 保护)。
|
||
// 启用本地预缓存后,首次访问回源下载并落盘,后续直接读本地缓存(断网也能查看)。
|
||
// 两端点均把文件流原样转发并透传 Content-Type,不套 {code,message,data}。
|
||
|
||
// pdfQueryHandler GET /api/pdf?name=<file>(需登录)
|
||
func pdfQueryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
forwardFile(w, ctx.Mes, ctx.PdfCache, strings.TrimSpace(r.URL.Query().Get("name")))
|
||
}
|
||
}
|
||
|
||
// pdfOpenHandler GET /pdfopen/<name>(免鉴权,供 iframe 直链;仅单层文件名)
|
||
func pdfOpenHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
raw := strings.TrimPrefix(r.URL.EscapedPath(), "/pdfopen/")
|
||
name, err := url.PathUnescape(raw)
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "非法文件名")
|
||
return
|
||
}
|
||
forwardFile(w, ctx.Mes, ctx.PdfCache, name)
|
||
}
|
||
}
|
||
|
||
// pdfViewHandler GET /pdfview?name=<encoded>(免鉴权,注册在鉴权中间件之前的路由组)。
|
||
// 以 query 传文件名,天然支持 MES 落盘的「日期子目录/文件名」(如 2026-09-16/xxx.pdf),
|
||
// 供工艺流程 PDF 与工序步骤图纸附件(Item J)统一使用。
|
||
func pdfViewHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
forwardFile(w, ctx.Mes, ctx.PdfCache, strings.TrimSpace(r.URL.Query().Get("name")))
|
||
}
|
||
}
|
||
|
||
// forwardFile 校验文件名后拉取 MES 文件流并原样转发;优先命中本地预缓存。
|
||
// 允许「单层日期子目录/文件名」(MES 上传落盘格式),拒绝路径穿越与多级/绝对路径。
|
||
func forwardFile(w http.ResponseWriter, mesc *mes.Client, cache *pdfcache.Cache, name string) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
fail(w, http.StatusBadRequest, "缺少文件名")
|
||
return
|
||
}
|
||
// 统一存储后 name 为多级相对路径(年/月/日/文件类型/uuid.ext),逐段校验:
|
||
// 禁止 ../ 穿越、反斜杠、绝对路径、空段、以及非法字符;段数上限防超长构造。
|
||
if !validRelPath(name) {
|
||
fail(w, http.StatusBadRequest, "非法文件名")
|
||
return
|
||
}
|
||
|
||
// 1) 命中本地预缓存 → 直接回源本地文件
|
||
if p, ok := cache.Get(name); ok {
|
||
f, err := os.Open(p)
|
||
if err == nil {
|
||
stream(f, w, name, "")
|
||
return
|
||
}
|
||
f.Close()
|
||
}
|
||
|
||
// 2) 回源 MES 下载(内部文件接口按 query 参数 name 读取,支持日期子目录)
|
||
body, header, err := mesc.Get("/api/internal/files", url.Values{"name": {name}})
|
||
if err != nil {
|
||
fail(w, http.StatusBadGateway, "工艺文件获取失败:"+errString(err))
|
||
return
|
||
}
|
||
|
||
// 3) 写入本地预缓存(失败不影响本次响应)
|
||
_, _ = cache.Put(name, body)
|
||
streamBytes(body, w, name, header.Get("Content-Type"))
|
||
}
|
||
|
||
// validRelPath 校验「年/月/日/文件类型/uuid.ext」这类多级相对路径是否安全。
|
||
// 规则:非空、最多 8 段、无绝对路径/反斜杠/穿越段、每段仅允许字母数字与 . _ -。
|
||
func validRelPath(name string) bool {
|
||
if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
|
||
strings.Contains(name, "\\") || strings.Contains(name, "..") {
|
||
return false
|
||
}
|
||
parts := strings.Split(name, "/")
|
||
if len(parts) > 8 {
|
||
return false
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" || p == "." || p == ".." {
|
||
return false
|
||
}
|
||
for _, r := range p {
|
||
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||
(r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||
if !ok {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func stream(f io.ReadCloser, w http.ResponseWriter, name, contentType string) {
|
||
defer f.Close()
|
||
ct := contentType
|
||
if ct == "" {
|
||
ct = "application/pdf"
|
||
}
|
||
w.Header().Set("Content-Type", ct)
|
||
w.Header().Set("X-Original-Filename", strconv.Quote(name))
|
||
w.Header().Set("X-PDF-Cache", "hit")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.Copy(w, f)
|
||
}
|
||
|
||
func streamBytes(body []byte, w http.ResponseWriter, name, contentType string) {
|
||
ct := contentType
|
||
if ct == "" {
|
||
ct = "application/pdf"
|
||
}
|
||
w.Header().Set("Content-Type", ct)
|
||
w.Header().Set("X-Original-Filename", strconv.Quote(name))
|
||
w.Header().Set("X-PDF-Cache", "miss")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(body)
|
||
}
|