Files
SunYF 4a5e2d7bac feat: 完成多模块迭代更新
- 新增过程巡检按步骤上报、数量不符上报、工位退料功能
- 增加工单工程编号三级归属字段
- 新增物料安全库存与缺货预警
- 新增附件管理、退库单管理模块
- 优化生产看板展示逻辑与前端页面文案
- 清理冗余的工艺路线相关代码与备份文件
2026-09-15 16:37:39 +08:00

201 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"bj_power_wms/ent"
"bj_power_wms/ent/attachment"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
)
const (
// maxAttachmentSize 单文件上限 20MB(图纸 PDF/检验报告足够;超限直接拒绝,避免拖垮服务)
maxAttachmentSize = 20 << 20
// uploadRoot 附件磁盘根目录(相对服务运行目录)
uploadRoot = "uploads"
)
// allowedAttachmentExt 允许的附件扩展名白名单(图纸/报告/图片/表格)。
// 白名单而非黑名单:避免上传可执行文件。
var allowedAttachmentExt = map[string]bool{
".pdf": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".xlsx": true, ".xls": true, ".doc": true, ".docx": true,
".dwg": true, ".dxf": true, ".zip": true, ".txt": true, ".csv": true,
}
// uploadAttachmentHandler 上传附件(multipart/form-data
// 表单字段:bizType、bizId、file
// 落盘:uploads/<uuid><ext>(纯 ASCII 名,规避中文 URL 编码问题),原始文件名存库。
func uploadAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxAttachmentSize); err != nil {
fail(w, http.StatusBadRequest, "解析上传表单失败(单文件不超过20MB): "+err.Error())
return
}
bizType := strings.TrimSpace(r.FormValue("bizType"))
bizId := strings.TrimSpace(r.FormValue("bizId"))
if bizType == "" || bizId == "" {
fail(w, http.StatusBadRequest, "bizType 与 bizId 必填")
return
}
file, header, err := r.FormFile("file")
if err != nil {
fail(w, http.StatusBadRequest, "未取到上传文件: "+err.Error())
return
}
defer file.Close()
if header.Size > maxAttachmentSize {
fail(w, http.StatusBadRequest, "文件超过 20MB 上限,请压缩后重传")
return
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowedAttachmentExt[ext] {
fail(w, http.StatusBadRequest, "不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)")
return
}
if err := os.MkdirAll(uploadRoot, 0o755); err != nil {
fail(w, http.StatusInternalServerError, "创建附件目录失败: "+err.Error())
return
}
storedName := uuid.NewString() + ext
dst := filepath.Join(uploadRoot, storedName)
out, err := os.Create(dst)
if err != nil {
fail(w, http.StatusInternalServerError, "写入附件失败: "+err.Error())
return
}
defer out.Close()
size, err := io.Copy(out, file)
if err != nil {
_ = os.Remove(dst)
fail(w, http.StatusInternalServerError, "保存附件失败: "+err.Error())
return
}
rec, err := ctx.EntClient.Attachment.Create().
SetBizType(bizType).
SetBizID(bizId).
SetFileName(header.Filename).
SetFilePath(storedName).
SetFileSize(size).
SetNillableMimeType(strPtr(header.Header.Get("Content-Type"))).
SetNillableUploadedBy(strPtr(r.Header.Get("X-Username"))).
Save(ctx0())
if err != nil {
_ = os.Remove(dst)
fail(w, http.StatusInternalServerError, "登记附件失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "attachment.upload", r.Header.Get("X-Username"), bizType, bizId,
"上传附件 "+header.Filename, map[string]any{"fileSize": size})
ok(w, attachmentView(rec))
}
}
// attachmentView 附件返回视图:附带可直接预览的 URL
type attachmentViewRow struct {
ID int64 `json:"id"`
BizType string `json:"bizType"`
BizID string `json:"bizId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
MimeType string `json:"mimeType"`
UploadedBy string `json:"uploadedBy"`
CreatedAt int64 `json:"createdAt"`
URL string `json:"url"`
}
func attachmentView(rec *ent.Attachment) *attachmentViewRow {
return &attachmentViewRow{
ID: int64(rec.ID),
BizType: rec.BizType,
BizID: rec.BizID,
FileName: rec.FileName,
FileSize: rec.FileSize,
MimeType: rec.MimeType,
UploadedBy: rec.UploadedBy,
CreatedAt: rec.CreatedAt,
URL: "/uploads/" + rec.FilePath,
}
}
// listAttachmentsHandler 查询某业务对象的附件列表
// GET /api/attachments?bizType=&bizId=
func listAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bizType := r.URL.Query().Get("bizType")
bizId := r.URL.Query().Get("bizId")
if bizType == "" || bizId == "" {
fail(w, http.StatusBadRequest, "bizType 与 bizId 必填")
return
}
list, err := ctx.EntClient.Attachment.Query().
Where(attachment.BizTypeEQ(bizType), attachment.BizIDEQ(bizId)).
Order(ent.Desc("created_at"), ent.Desc("id")).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
rows := make([]*attachmentViewRow, 0, len(list))
for _, rec := range list {
rows = append(rows, attachmentView(rec))
}
ok(w, map[string]any{"list": rows, "total": len(rows)})
}
}
// deleteAttachmentHandler 删除附件(同时删除磁盘文件)
// POST /api/attachments/delete body: { id }
func deleteAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
}
if err := parseJSON(r, &req); err != nil || req.ID <= 0 {
fail(w, http.StatusBadRequest, "参数错误:id 必填")
return
}
rec, err := ctx.EntClient.Attachment.Get(ctx0(), int(req.ID))
if err != nil {
fail(w, http.StatusNotFound, "附件不存在")
return
}
if err := ctx.EntClient.Attachment.DeleteOneID(int(req.ID)).Exec(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "删除附件失败: "+err.Error())
return
}
_ = os.Remove(filepath.Join(uploadRoot, filepath.Base(rec.FilePath)))
ctx.EventLog.Write(ctx0(), "attachment.delete", r.Header.Get("X-Username"), rec.BizType, rec.BizID,
"删除附件 "+rec.FileName, nil)
ok(w, map[string]any{"id": req.ID})
}
}
// serveUploadFileHandler 附件静态文件服务(PDF 预览/图片查看),免鉴权。
// GET /uploads/:name —— 仅允许扁平文件名(uuid+ext),显式阻断目录穿越。
func serveUploadFileHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := filepath.Base(r.URL.Path) // 只取最后一段,消灭 ../ 穿越
if name == "" || name == "." || name == "/" {
http.NotFound(w, r)
return
}
p := filepath.Join(uploadRoot, name)
if _, err := os.Stat(p); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, p)
}
}