Files
bj_power/bj_power_wms/internal/handler/attachment.go
T

201 lines
6.5 KiB
Go
Raw Normal View History

2026-09-15 16:37:39 +08:00
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)
}
}