1. 新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警 2. 重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑 3. 对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库 4. 实现入库单作废功能与区域库位的多级父子结构管理 5. 删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径 6. 新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
596 lines
20 KiB
Go
596 lines
20 KiB
Go
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
|
||
}
|
||
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/:name(name = 附件 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)
|
||
}
|
||
}
|