2026-09-15 16:37:39 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"crypto/md5"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"fmt"
|
2026-09-15 16:37:39 +08:00
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"net/url"
|
2026-09-15 16:37:39 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"strconv"
|
2026-09-15 16:37:39 +08:00
|
|
|
|
"strings"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"time"
|
2026-09-15 16:37:39 +08:00
|
|
|
|
|
|
|
|
|
|
"bj_power_wms/ent"
|
|
|
|
|
|
"bj_power_wms/ent/attachment"
|
|
|
|
|
|
"bj_power_wms/internal/svc"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"github.com/zeromicro/go-zero/core/logx"
|
2026-09-15 16:37:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// 文件类型(大写枚举):决定存储目录 <根目录>/年/月/日/<文件类型>/<uuid>.<ext>。
|
|
|
|
|
|
// 与 biz_type(业务对象:inbound/material/inspection/semi/outbound)正交——
|
|
|
|
|
|
// 目录按"文件类型"分层,业务归属仍由 biz_type+biz_id 表达。
|
2026-09-15 16:37:39 +08:00
|
|
|
|
const (
|
2026-09-19 10:54:15 +08:00
|
|
|
|
FileTypeProcessPDF = "PROCESS_PDF" // 工艺流程作业指导书
|
|
|
|
|
|
FileTypeInspectionPhoto = "INSPECTION_PHOTO" // 检验照片
|
|
|
|
|
|
FileTypeInspectionPDF = "INSPECTION_PDF" // 检验报告
|
|
|
|
|
|
FileTypeDrawing = "DRAWING" // 图纸
|
|
|
|
|
|
FileTypeProcessCard = "PROCESS_CARD" // 流程卡
|
|
|
|
|
|
FileTypePackagePhoto = "PACKAGE_PHOTO" // 包装照片
|
|
|
|
|
|
FileTypeExcelImport = "EXCEL_IMPORT" // Excel 导入
|
|
|
|
|
|
FileTypeOther = "OTHER" // 其他
|
2026-09-15 16:37:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
var validFileTypes = map[string]bool{
|
|
|
|
|
|
FileTypeProcessPDF: true, FileTypeInspectionPhoto: true, FileTypeInspectionPDF: true,
|
|
|
|
|
|
FileTypeDrawing: true, FileTypeProcessCard: true, FileTypePackagePhoto: true,
|
|
|
|
|
|
FileTypeExcelImport: true, FileTypeOther: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// allowedAttachmentExt 允许的附件扩展名白名单(图纸/报告/图片/表格)。白名单而非黑名单:避免上传可执行文件。
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// attRoot 附件根目录(etc/*.yaml 的 Attachment.RootDir;缺省 attachments,不硬编码绝对路径)。
|
|
|
|
|
|
func attRoot(ctx *svc.ServiceContext) string {
|
|
|
|
|
|
if ctx != nil && strings.TrimSpace(ctx.Config.Attachment.RootDir) != "" {
|
|
|
|
|
|
return strings.TrimSpace(ctx.Config.Attachment.RootDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
return "attachments"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// maxSizeFor 单文件上限:按文件类型取配置,未配置用 DefaultMaxSize。
|
|
|
|
|
|
func maxSizeFor(ctx *svc.ServiceContext, fileType string) int64 {
|
|
|
|
|
|
cfg := ctx.Config.Attachment
|
|
|
|
|
|
if cfg.MaxSize != nil {
|
|
|
|
|
|
if v, ok := cfg.MaxSize[fileType]; ok && v > 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.DefaultMaxSize > 0 {
|
|
|
|
|
|
return cfg.DefaultMaxSize
|
|
|
|
|
|
}
|
|
|
|
|
|
return 20 << 20
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// deriveFileType 未显式指定文件类型时,按业务类型 + 扩展名推断,兜底 OTHER。
|
|
|
|
|
|
func deriveFileType(bizType, ext string) string {
|
|
|
|
|
|
ext = strings.ToLower(ext)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case ext == ".xlsx" || ext == ".xls" || ext == ".csv":
|
|
|
|
|
|
return FileTypeExcelImport
|
|
|
|
|
|
case bizType == "inspection":
|
|
|
|
|
|
if ext == ".pdf" {
|
|
|
|
|
|
return FileTypeInspectionPDF
|
|
|
|
|
|
}
|
|
|
|
|
|
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" {
|
|
|
|
|
|
return FileTypeInspectionPhoto
|
|
|
|
|
|
}
|
|
|
|
|
|
case bizType == "material" && (ext == ".pdf" || ext == ".dwg" || ext == ".dxf"):
|
|
|
|
|
|
return FileTypeDrawing
|
|
|
|
|
|
case bizType == "outbound":
|
|
|
|
|
|
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" {
|
|
|
|
|
|
return FileTypePackagePhoto
|
|
|
|
|
|
}
|
|
|
|
|
|
if ext == ".pdf" {
|
|
|
|
|
|
return FileTypeProcessPDF
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return FileTypeOther
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildRelDir 相对目录 年/月/日/文件类型(月/日补零)。
|
|
|
|
|
|
func buildRelDir(fileType string, t time.Time) string {
|
|
|
|
|
|
return fmt.Sprintf("%d/%02d/%02d/%s", t.Year(), int(t.Month()), t.Day(), fileType)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// safeAbsPath 把库内相对路径安全解析为根目录内的绝对路径(阻断 ../ 穿越)。
|
|
|
|
|
|
func safeAbsPath(root, rel string) (string, bool) {
|
|
|
|
|
|
rel = strings.ReplaceAll(rel, "\\", "/")
|
|
|
|
|
|
clean := filepath.Clean(filepath.Join(root, rel))
|
|
|
|
|
|
rootAbs, err := filepath.Abs(root)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
abs, err := filepath.Abs(clean)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
if abs != rootAbs && !strings.HasPrefix(abs, rootAbs+string(os.PathSeparator)) {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
return abs, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// uploadAttachmentHandler 上传附件(multipart/form-data,流式写入)。
|
|
|
|
|
|
// 表单字段:bizType、bizId、file、fileType(可选,大写枚举)。
|
|
|
|
|
|
// 落盘:<根目录>/年/月/日/<文件类型>/<uuid>.<ext>;库内只存相对路径。
|
2026-09-15 16:37:39 +08:00
|
|
|
|
func uploadAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
|
|
|
|
|
|
// 允许继续(大文件会经 MultipartReader 流式读取;此处仅先解析小字段)
|
2026-09-15 16:37:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
bizType := strings.TrimSpace(r.FormValue("bizType"))
|
|
|
|
|
|
bizId := strings.TrimSpace(r.FormValue("bizId"))
|
2026-09-19 10:54:15 +08:00
|
|
|
|
fileTypeReq := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
|
|
|
|
if ext == "" || !allowedAttachmentExt[ext] {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
fileType := fileTypeReq
|
|
|
|
|
|
if fileType == "" || !validFileTypes[fileType] {
|
|
|
|
|
|
fileType = deriveFileType(bizType, ext)
|
|
|
|
|
|
}
|
|
|
|
|
|
maxSize := maxSizeFor(ctx, fileType)
|
|
|
|
|
|
if header.Size > 0 && header.Size > maxSize {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, fmt.Sprintf("文件超过 %dMB 上限,请压缩后重传", maxSize>>20))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-15 16:37:39 +08:00
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// 流式落盘:边写边算 MD5,内存占用恒定(绝不 ReadAll)
|
|
|
|
|
|
root := attRoot(ctx)
|
|
|
|
|
|
relDir := buildRelDir(fileType, timeNow())
|
|
|
|
|
|
absDir, okPath := safeAbsPath(root, relDir)
|
|
|
|
|
|
if !okPath {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "非法存储路径")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
fail(w, http.StatusInternalServerError, "创建附件目录失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
storedName := uuid.NewString() + ext
|
2026-09-19 10:54:15 +08:00
|
|
|
|
relPath := relDir + "/" + storedName
|
|
|
|
|
|
dst := filepath.Join(absDir, storedName)
|
|
|
|
|
|
|
2026-09-15 16:37:39 +08:00
|
|
|
|
out, err := os.Create(dst)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "写入附件失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
hash := md5.New()
|
|
|
|
|
|
written, err := io.Copy(io.MultiWriter(out, hash), io.LimitReader(file, maxSize+1))
|
|
|
|
|
|
_ = out.Close()
|
2026-09-15 16:37:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = os.Remove(dst)
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "保存附件失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if written > maxSize {
|
|
|
|
|
|
_ = os.Remove(dst)
|
|
|
|
|
|
fail(w, http.StatusBadRequest, fmt.Sprintf("文件超过 %dMB 上限,请压缩后重传", maxSize>>20))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
fileMD5 := hex.EncodeToString(hash.Sum(nil))
|
|
|
|
|
|
|
|
|
|
|
|
// MD5 秒传:同业务对象 + 同 MD5 已存在 → 直接返回既有记录(不重复落盘)
|
|
|
|
|
|
if exist, e := ctx.EntClient.Attachment.Query().
|
|
|
|
|
|
Where(attachment.BizTypeEQ(bizType), attachment.BizIDEQ(bizId),
|
|
|
|
|
|
attachment.FileMd5EQ(fileMD5), attachment.DeletedEQ(false)).
|
|
|
|
|
|
Only(ctx0()); e == nil && exist != nil {
|
|
|
|
|
|
_ = os.Remove(dst)
|
|
|
|
|
|
ok(w, attachmentView(ctx, exist))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-15 16:37:39 +08:00
|
|
|
|
|
|
|
|
|
|
rec, err := ctx.EntClient.Attachment.Create().
|
|
|
|
|
|
SetBizType(bizType).
|
|
|
|
|
|
SetBizID(bizId).
|
|
|
|
|
|
SetFileName(header.Filename).
|
2026-09-19 10:54:15 +08:00
|
|
|
|
SetFilePath(relPath).
|
|
|
|
|
|
SetFileType(fileType).
|
|
|
|
|
|
SetFileExt(strings.TrimPrefix(ext, ".")).
|
|
|
|
|
|
SetFileMd5(fileMD5).
|
|
|
|
|
|
SetFileSize(written).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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,
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"上传附件 "+header.Filename, map[string]any{"fileSize": written, "fileType": fileType})
|
|
|
|
|
|
ok(w, attachmentView(ctx, rec))
|
2026-09-15 16:37:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// attachmentViewRow 附件返回视图:附带可直接预览/下载的 URL(按 id 提供,避免中文/多级路径问题)。
|
2026-09-15 16:37:39 +08:00
|
|
|
|
type attachmentViewRow struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
BizType string `json:"bizType"`
|
|
|
|
|
|
BizID string `json:"bizId"`
|
2026-09-19 10:54:15 +08:00
|
|
|
|
FileType string `json:"fileType"`
|
2026-09-15 16:37:39 +08:00
|
|
|
|
FileName string `json:"fileName"`
|
|
|
|
|
|
FileSize int64 `json:"fileSize"`
|
2026-09-19 10:54:15 +08:00
|
|
|
|
FileExt string `json:"fileExt"`
|
2026-09-15 16:37:39 +08:00
|
|
|
|
MimeType string `json:"mimeType"`
|
|
|
|
|
|
UploadedBy string `json:"uploadedBy"`
|
|
|
|
|
|
CreatedAt int64 `json:"createdAt"`
|
2026-09-19 10:54:15 +08:00
|
|
|
|
Archived bool `json:"archived"`
|
|
|
|
|
|
Deleted bool `json:"deleted"`
|
2026-09-15 16:37:39 +08:00
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
func attachmentView(ctx *svc.ServiceContext, rec *ent.Attachment) *attachmentViewRow {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
return &attachmentViewRow{
|
2026-09-19 10:54:15 +08:00
|
|
|
|
ID: int64(rec.ID), BizType: rec.BizType, BizID: rec.BizID, FileType: rec.FileType,
|
|
|
|
|
|
FileName: rec.FileName, FileSize: rec.FileSize, FileExt: rec.FileExt, MimeType: rec.MimeType,
|
|
|
|
|
|
UploadedBy: rec.UploadedBy, CreatedAt: rec.CreatedAt, Archived: rec.Archived, Deleted: rec.Deleted,
|
|
|
|
|
|
URL: "/uploads/" + strconv.FormatInt(int64(rec.ID), 10),
|
2026-09-15 16:37:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// listAttachmentsHandler 查询某业务对象的附件列表(排除逻辑删除)
|
2026-09-15 16:37:39 +08:00
|
|
|
|
// 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().
|
2026-09-19 10:54:15 +08:00
|
|
|
|
Where(attachment.BizTypeEQ(bizType), attachment.BizIDEQ(bizId), attachment.DeletedEQ(false)).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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 {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
rows = append(rows, attachmentView(ctx, rec))
|
2026-09-15 16:37:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
ok(w, map[string]any{"list": rows, "total": len(rows)})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// listAllAttachmentsHandler 附件中心:全量分页查询(按文件类型/业务类型/业务标识/文件名/时间筛选)
|
|
|
|
|
|
// GET /api/attachments/all?fileType=&bizType=&bizId=&fileName=&startDate=&endDate=&includeDeleted=&page=&pageSize=
|
|
|
|
|
|
func listAllAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
page := atoi(r.URL.Query().Get("page"), 1)
|
|
|
|
|
|
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
|
|
|
|
|
|
if page < 1 {
|
|
|
|
|
|
page = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if pageSize < 1 || pageSize > 200 {
|
|
|
|
|
|
pageSize = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
q := ctx.EntClient.Attachment.Query()
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("fileType")); v != "" {
|
|
|
|
|
|
q = q.Where(attachment.FileTypeEQ(strings.ToUpper(v)))
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("bizType")); v != "" {
|
|
|
|
|
|
q = q.Where(attachment.BizTypeEQ(v))
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("bizId")); v != "" {
|
|
|
|
|
|
q = q.Where(attachment.BizIDContainsFold(v))
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("fileName")); v != "" {
|
|
|
|
|
|
q = q.Where(attachment.FileNameContainsFold(v))
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("startDate")); v != "" {
|
|
|
|
|
|
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
|
|
|
|
|
q = q.Where(attachment.CreatedAtGTE(t.Unix()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := strings.TrimSpace(r.URL.Query().Get("endDate")); v != "" {
|
|
|
|
|
|
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
|
|
|
|
|
q = q.Where(attachment.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.URL.Query().Get("includeDeleted") != "true" {
|
|
|
|
|
|
q = q.Where(attachment.DeletedEQ(false))
|
|
|
|
|
|
}
|
|
|
|
|
|
total, err := q.Count(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
|
|
|
|
|
|
Offset((page - 1) * pageSize).Limit(pageSize).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(ctx, rec))
|
|
|
|
|
|
}
|
|
|
|
|
|
// 磁盘状态一并返回,供页面顶部告警条展示
|
|
|
|
|
|
ok(w, map[string]any{"total": total, "list": rows, "page": page, "pageSize": pageSize,
|
|
|
|
|
|
"disk": diskStatus(ctx)})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// deleteAttachmentHandler 删除附件(逻辑删除:DB 标记 deleted,物理文件由归档任务统一处理)
|
2026-09-15 16:37:39 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if _, err := ctx.EntClient.Attachment.UpdateOneID(int(req.ID)).
|
|
|
|
|
|
SetDeleted(true).SetDeletedAt(timeNow().Unix()).
|
|
|
|
|
|
SetNillableDeletedBy(strPtr(r.Header.Get("X-Username"))).
|
|
|
|
|
|
Save(ctx0()); err != nil {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
fail(w, http.StatusInternalServerError, "删除附件失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
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})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// serveUploadFileHandler 附件文件服务(按 id 提供,免鉴权便于 <img>/新窗口预览;支持 Range 断点续传)。
|
|
|
|
|
|
// GET /uploads/:name(name = 附件 id,或历史扁平文件名兜底)
|
|
|
|
|
|
func serveUploadFileHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
name := filepath.Base(r.URL.Path)
|
2026-09-15 16:37:39 +08:00
|
|
|
|
if name == "" || name == "." || name == "/" {
|
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
root := attRoot(ctx)
|
|
|
|
|
|
var absPath string
|
|
|
|
|
|
var fileName string
|
|
|
|
|
|
if id, e := strconv.ParseInt(name, 10, 64); e == nil && id > 0 {
|
|
|
|
|
|
rec, err := ctx.EntClient.Attachment.Get(ctx0(), int(id))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
p, ok := safeAbsPath(root, rec.FilePath)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
absPath, fileName = p, rec.FileName
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 历史扁平文件(uuid.ext)兜底
|
|
|
|
|
|
p, ok := safeAbsPath(root, name)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
absPath, fileName = p, name
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := os.Stat(absPath); err != nil {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if fileName != "" {
|
|
|
|
|
|
// 中文文件名走 RFC 5987;下载(dl=1)才强制 attachment,否则内联预览
|
|
|
|
|
|
disp := "inline"
|
|
|
|
|
|
if r.URL.Query().Get("dl") == "1" {
|
|
|
|
|
|
disp = "attachment"
|
|
|
|
|
|
}
|
|
|
|
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename*=UTF-8''%s`, disp, url.QueryEscape(fileName)))
|
|
|
|
|
|
}
|
|
|
|
|
|
http.ServeFile(w, r, absPath) // ServeFile 自带 Range 支持
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// diskStatusView 磁盘状态视图(供接口/页面告警条使用)。
|
|
|
|
|
|
type diskStatusView struct {
|
|
|
|
|
|
RootDir string `json:"rootDir"`
|
|
|
|
|
|
UsedPercent int `json:"usedPercent"`
|
|
|
|
|
|
FreeBytes uint64 `json:"freeBytes"`
|
|
|
|
|
|
TotalBytes uint64 `json:"totalBytes"`
|
|
|
|
|
|
WarnPercent int `json:"warnPercent"`
|
|
|
|
|
|
CritPercent int `json:"critPercent"`
|
|
|
|
|
|
Level string `json:"level"` // ok / warn / critical
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
CheckedUnix int64 `json:"checkedAt"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// diskStatus 读取附件根目录所在磁盘剩余空间并给出告警级别。
|
|
|
|
|
|
func diskStatus(ctx *svc.ServiceContext) diskStatusView {
|
|
|
|
|
|
cfg := ctx.Config.Attachment
|
|
|
|
|
|
root := attRoot(ctx)
|
|
|
|
|
|
v := diskStatusView{RootDir: root, WarnPercent: cfg.DiskWarnPercent, CritPercent: cfg.DiskCriticalPercent,
|
|
|
|
|
|
Level: "ok", Message: "磁盘空间正常", CheckedUnix: timeNow().Unix()}
|
|
|
|
|
|
used, free, total, err := diskUsage(root)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 目录尚不存在时退化为父目录统计
|
|
|
|
|
|
_ = os.MkdirAll(root, 0o755)
|
|
|
|
|
|
used, free, total, err = diskUsage(root)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
v.Level = "unknown"
|
|
|
|
|
|
v.Message = "磁盘空间检测失败:" + err.Error()
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
v.UsedPercent, v.FreeBytes, v.TotalBytes = used, free, total
|
|
|
|
|
|
freePercent := 100 - used
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case freePercent < cfg.DiskCriticalPercent:
|
|
|
|
|
|
v.Level = "critical"
|
|
|
|
|
|
v.Message = fmt.Sprintf("磁盘剩余空间仅 %d%%,低于 %d%%,请立即执行备份归档清理(附件中心可一键归档)", freePercent, cfg.DiskCriticalPercent)
|
|
|
|
|
|
case freePercent < cfg.DiskWarnPercent:
|
|
|
|
|
|
v.Level = "warn"
|
|
|
|
|
|
v.Message = fmt.Sprintf("磁盘剩余空间 %d%%,低于 %d%% 告警线,请及时清理", freePercent, cfg.DiskWarnPercent)
|
|
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// diskStatusHandler GET /api/attachments/disk-status
|
|
|
|
|
|
func diskStatusHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
ok(w, diskStatus(ctx))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// archiveAttachmentsHandler 归档:把早于「本地保留年数」的附件移动到外部备份目录(ArchiveDir)
|
|
|
|
|
|
// 并标记 archived=TRUE(保留索引,可追溯)。ArchiveDir 为空时仅标记不移动。
|
|
|
|
|
|
// POST /api/attachments/archive body: { dryRun }
|
|
|
|
|
|
func archiveAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
DryRun bool `json:"dryRun"`
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = parseJSON(r, &req)
|
|
|
|
|
|
cfg := ctx.Config.Attachment
|
|
|
|
|
|
root := attRoot(ctx)
|
|
|
|
|
|
years := cfg.LocalRetentionYears
|
|
|
|
|
|
if years <= 0 {
|
|
|
|
|
|
years = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
cutYear := timeNow().Year() - years // 早于该年份的附件可归档
|
|
|
|
|
|
recs, err := ctx.EntClient.Attachment.Query().
|
|
|
|
|
|
Where(attachment.ArchivedEQ(false), attachment.DeletedEQ(false)).
|
|
|
|
|
|
All(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
moved, marked, failed := 0, 0, 0
|
|
|
|
|
|
for _, rec := range recs {
|
|
|
|
|
|
if int(time.Unix(rec.CreatedAt, 0).Year()) > cutYear-1 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.DryRun {
|
|
|
|
|
|
marked++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.ArchiveDir != "" {
|
|
|
|
|
|
src, ok1 := safeAbsPath(root, rec.FilePath)
|
|
|
|
|
|
dst, ok2 := safeAbsPath(cfg.ArchiveDir, rec.FilePath)
|
|
|
|
|
|
if ok1 && ok2 {
|
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
|
|
|
|
|
if err := os.Rename(src, dst); err == nil {
|
|
|
|
|
|
moved++
|
|
|
|
|
|
} else if err := copyFile(src, dst); err == nil {
|
|
|
|
|
|
_ = os.Remove(src)
|
|
|
|
|
|
moved++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := ctx.EntClient.Attachment.UpdateOneID(rec.ID).
|
|
|
|
|
|
SetArchived(true).SetArchivedAt(timeNow().Unix()).Save(ctx0()); err != nil {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
marked++
|
|
|
|
|
|
}
|
|
|
|
|
|
if !req.DryRun {
|
|
|
|
|
|
ctx.EventLog.Write(ctx0(), "attachment.archive", r.Header.Get("X-Username"), "attachment", "archive",
|
|
|
|
|
|
fmt.Sprintf("附件归档:移动%d 标记%d 失败%d(早于 %d 年)", moved, marked, failed, cutYear),
|
|
|
|
|
|
map[string]any{"moved": moved, "marked": marked, "failed": failed})
|
|
|
|
|
|
}
|
|
|
|
|
|
ok(w, map[string]any{"moved": moved, "marked": marked, "failed": failed,
|
|
|
|
|
|
"cutYear": cutYear, "archiveDir": cfg.ArchiveDir, "dryRun": req.DryRun})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// copyFile 兜底复制(跨盘符 Rename 失败时用)。
|
|
|
|
|
|
func copyFile(src, dst string) error {
|
|
|
|
|
|
in, err := os.Open(src)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer in.Close()
|
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
out, err := os.Create(dst)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
|
|
|
|
_ = out.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return out.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// StartDiskMonitor 启动磁盘空间监控:每天 DiskCheckHour(默认7点) 检测一次,
|
|
|
|
|
|
// 剩余空间 <20% 告警、<10% 提示清理(写日志 + 操作日志,页面顶部同步提示)。
|
|
|
|
|
|
func StartDiskMonitor(ctx *svc.ServiceContext) {
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
hour := ctx.Config.Attachment.DiskCheckHour
|
|
|
|
|
|
if hour < 0 || hour > 23 {
|
|
|
|
|
|
hour = 7
|
|
|
|
|
|
}
|
|
|
|
|
|
for {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.Local)
|
|
|
|
|
|
if !next.After(now) {
|
|
|
|
|
|
next = next.Add(24 * time.Hour)
|
|
|
|
|
|
}
|
|
|
|
|
|
time.Sleep(time.Until(next))
|
|
|
|
|
|
checkDiskOnce(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func checkDiskOnce(ctx *svc.ServiceContext) {
|
|
|
|
|
|
st := diskStatus(ctx)
|
|
|
|
|
|
switch st.Level {
|
|
|
|
|
|
case "critical":
|
|
|
|
|
|
logx.Errorf("[磁盘监控] %s(已用 %d%%,剩余 %d%%)", st.Message, st.UsedPercent, 100-st.UsedPercent)
|
|
|
|
|
|
ctx.EventLog.Write(ctx0(), "system.disk", "system", "disk", "attachment",
|
|
|
|
|
|
st.Message, map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
|
|
|
|
|
|
case "warn":
|
|
|
|
|
|
logx.Alert("[磁盘监控] " + st.Message)
|
|
|
|
|
|
ctx.EventLog.Write(ctx0(), "system.disk", "system", "disk", "attachment",
|
|
|
|
|
|
st.Message, map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
|
|
|
|
|
|
default:
|
|
|
|
|
|
logx.Infof("[磁盘监控] 正常:剩余 %d%%(%s)", 100-st.UsedPercent, st.RootDir)
|
2026-09-15 16:37:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|