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/(纯 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) } }