Files
bj_power/bj_power_wms/internal/handler/attachment.go
T
SunYF 60eacc9c4f style(formatter): 格式化代码中的缩进和空行
- 统一了结构体字段的对齐方式,保持一致的缩进
- 调整了 import 语句的顺序,将 fmt 移至合适位置
- 修复了多处多余的空行和空白字符
- 规范化了注释的缩进格式
- 整理了路由配置的缩进层次
- 调整了常量定义的对齐格式
2026-09-20 14:53:14 +08:00

726 lines
25 KiB
Go
Raw 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 (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/attachment"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
// 文件类型(大写枚举):决定存储目录 <根目录>/年/月/日/<文件类型>/<uuid>.<ext>。
// 与 biz_type(业务对象:inbound/material/inspection/semi/outbound)正交——
// 目录按"文件类型"分层,业务归属仍由 biz_type+biz_id 表达。
const (
FileTypeProcessPDF = "PROCESS_PDF" // 工艺流程作业指导书
FileTypeInspectionPhoto = "INSPECTION_PHOTO" // 检验照片
FileTypeInspectionPDF = "INSPECTION_PDF" // 检验报告
FileTypeDrawing = "DRAWING" // 图纸
FileTypeProcessCard = "PROCESS_CARD" // 流程卡
FileTypePackagePhoto = "PACKAGE_PHOTO" // 包装照片
FileTypeExcelImport = "EXCEL_IMPORT" // Excel 导入
FileTypeOther = "OTHER" // 其他
)
var validFileTypes = map[string]bool{
FileTypeProcessPDF: true, FileTypeInspectionPhoto: true, FileTypeInspectionPDF: true,
FileTypeDrawing: true, FileTypeProcessCard: true, FileTypePackagePhoto: true,
FileTypeExcelImport: true, FileTypeOther: true,
}
// 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,
}
// 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>;库内只存相对路径。
func uploadAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
// 允许继续(大文件会经 MultipartReader 流式读取;此处仅先解析小字段)
}
bizType := strings.TrimSpace(r.FormValue("bizType"))
bizId := strings.TrimSpace(r.FormValue("bizId"))
fileTypeReq := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
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
}
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
}
// 流式落盘:边写边算 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 {
fail(w, http.StatusInternalServerError, "创建附件目录失败: "+err.Error())
return
}
storedName := uuid.NewString() + ext
relPath := relDir + "/" + storedName
dst := filepath.Join(absDir, storedName)
out, err := os.Create(dst)
if err != nil {
fail(w, http.StatusInternalServerError, "写入附件失败: "+err.Error())
return
}
hash := md5.New()
written, err := io.Copy(io.MultiWriter(out, hash), io.LimitReader(file, maxSize+1))
_ = out.Close()
if err != nil {
_ = os.Remove(dst)
fail(w, http.StatusInternalServerError, "保存附件失败: "+err.Error())
return
}
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
}
rec, err := ctx.EntClient.Attachment.Create().
SetBizType(bizType).
SetBizID(bizId).
SetFileName(header.Filename).
SetFilePath(relPath).
SetFileType(fileType).
SetFileExt(strings.TrimPrefix(ext, ".")).
SetFileMd5(fileMD5).
SetFileSize(written).
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": written, "fileType": fileType})
ok(w, attachmentView(ctx, rec))
}
}
// attachmentViewRow 附件返回视图:附带可直接预览/下载的 URL(按 id 提供,避免中文/多级路径问题)。
type attachmentViewRow struct {
ID int64 `json:"id"`
BizType string `json:"bizType"`
BizID string `json:"bizId"`
FileType string `json:"fileType"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
FileExt string `json:"fileExt"`
MimeType string `json:"mimeType"`
UploadedBy string `json:"uploadedBy"`
CreatedAt int64 `json:"createdAt"`
Archived bool `json:"archived"`
Deleted bool `json:"deleted"`
URL string `json:"url"`
}
func attachmentView(ctx *svc.ServiceContext, rec *ent.Attachment) *attachmentViewRow {
return &attachmentViewRow{
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),
}
}
// 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), attachment.DeletedEQ(false)).
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(ctx, rec))
}
ok(w, map[string]any{"list": rows, "total": len(rows)})
}
}
// 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,物理文件由归档任务统一处理)
// 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
}
// 删除保护(需求1):关键业务附件禁止删除,避免质量/工艺记录丢失。
// 1) 工艺流程(PROCESS_PDF)、流程卡(PROCESS_CARD):全程禁删(工艺依据,需永久留存)。
// 2) 检验照片/报告(INSPECTION_*):未超本地保留年数禁删(质量追溯期内不可删)。
switch rec.FileType {
case FileTypeProcessPDF, FileTypeProcessCard:
fail(w, http.StatusForbidden, "工艺流程 / 流程卡为关键工艺文件,禁止删除")
return
case FileTypeInspectionPhoto, FileTypeInspectionPDF:
years := ctx.Config.Attachment.LocalRetentionYears
if years <= 0 {
years = 1
}
cutYear := timeNow().Year() - years
if time.Unix(rec.CreatedAt, 0).Year() >= cutYear {
fail(w, http.StatusForbidden, fmt.Sprintf("检验记录在质量追溯保留期(%d 年)内,禁止删除", years))
return
}
}
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 {
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})
}
}
// serveUploadFileHandler 附件文件服务(按 id 提供,免鉴权便于 <img>/新窗口预览;支持 Range 断点续传)。
// GET /uploads/:namename = 附件 id,或历史扁平文件名兜底)
func serveUploadFileHandler(ctx *svc.ServiceContext) 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
}
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 {
http.NotFound(w, r)
return
}
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)
}
}
// purgeDeletedHandler 物理清理已逻辑删除的附件(需求2):
// 扫描 deleted=true 的索引,删除对应磁盘文件(仅当文件未被归档到 ArchiveDir 时),
// 释放本地空间;索引保留(deleted 标记不变)以便追溯。默认 dryRun 预检,返回将清理数量与释放字节。
// POST /api/attachments/purge-deleted body: { dryRun }
func purgeDeletedHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DryRun bool `json:"dryRun"`
}
_ = parseJSON(r, &req)
root := attRoot(ctx)
recs, err := ctx.EntClient.Attachment.Query().
Where(attachment.DeletedEQ(true)).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
removed, freed, missing, failed := 0, int64(0), 0, 0
for _, rec := range recs {
// 已归档的文件已移至 ArchiveDir,不在本地,跳过
if rec.Archived {
missing++
continue
}
abs, okp := safeAbsPath(root, rec.FilePath)
if !okp {
failed++
continue
}
info, statErr := os.Stat(abs)
if statErr != nil {
missing++ // 文件已不存在(已清理或丢失),仅计数
continue
}
size := info.Size()
if req.DryRun {
removed++
freed += size
continue
}
if err := os.Remove(abs); err != nil {
failed++
continue
}
removed++
freed += size
}
if !req.DryRun {
ctx.EventLog.Write(ctx0(), "attachment.purge", r.Header.Get("X-Username"), "attachment", "purge",
fmt.Sprintf("物理清理已删附件:删除%d 释放%.2fMB 缺失%d 失败%d", removed, float64(freed)/1024/1024, missing, failed),
map[string]any{"removed": removed, "freed": freed, "missing": missing, "failed": failed})
}
ok(w, map[string]any{"removed": removed, "freed": freed, "missing": missing, "failed": failed, "dryRun": req.DryRun})
}
}
// cleanLogsHandler 日志清理(需求2,仅管理员):清理日志目录(Config.Log.Path)下
// 早于 N 天(默认 30)的 *.log 文件。与 logdaily 自动滚动清理互补,提供手动「立即清理」入口。
// 默认 dryRun 预检。POST /api/attachments/clean-logs body: { days, dryRun }
func cleanLogsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Days int `json:"days"`
DryRun bool `json:"dryRun"`
}
_ = parseJSON(r, &req)
days := req.Days
if days <= 0 {
days = 30 // 默认保留 30 天
}
dir := strings.TrimSpace(ctx.Config.Log.Path)
if dir == "" {
dir = "logs"
}
cutoff := timeNow().AddDate(0, 0, -days)
entries, err := os.ReadDir(dir)
if err != nil {
fail(w, http.StatusInternalServerError, "读取日志目录失败: "+err.Error())
return
}
removed, failed := 0, 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") {
continue
}
info, ierr := e.Info()
if ierr != nil {
continue
}
// 按文件名日期(base-2006-01-02.log)或修改时间判定,早于 cutoff 才清
if !info.ModTime().Before(cutoff) {
continue
}
if req.DryRun {
removed++
continue
}
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
failed++
continue
}
removed++
}
if !req.DryRun {
ctx.EventLog.Write(ctx0(), "system.cleanlog", r.Header.Get("X-Username"), "log", dir,
fmt.Sprintf("清理日志:删除%d 个早于%d天的日志文件,失败%d", removed, days, failed),
map[string]any{"removed": removed, "failed": failed, "days": days})
}
ok(w, map[string]any{"removed": removed, "failed": failed, "days": days, "dir": dir, "dryRun": req.DryRun})
}
}