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

596 lines
20 KiB
Go
Raw Normal View History

2026-09-15 16:37:39 +08:00
package handler
import (
"crypto/md5"
"encoding/hex"
"fmt"
2026-09-15 16:37:39 +08:00
"io"
"net/http"
"net/url"
2026-09-15 16:37:39 +08:00
"os"
"path/filepath"
"strconv"
2026-09-15 16:37:39 +08:00
"strings"
"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"
"github.com/zeromicro/go-zero/core/logx"
2026-09-15 16:37:39 +08:00
)
// 文件类型(大写枚举):决定存储目录 <根目录>/年/月/日/<文件类型>/<uuid>.<ext>。
// 与 biz_type(业务对象:inbound/material/inspection/semi/outbound)正交——
// 目录按"文件类型"分层,业务归属仍由 biz_type+biz_id 表达。
2026-09-15 16:37:39 +08:00
const (
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
)
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,
}
// 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) {
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"))
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
}
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
// 流式落盘:边写边算 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
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
}
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
}
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).
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,
"上传附件 "+header.Filename, map[string]any{"fileSize": written, "fileType": fileType})
ok(w, attachmentView(ctx, rec))
2026-09-15 16:37:39 +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"`
FileType string `json:"fileType"`
2026-09-15 16:37:39 +08:00
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
FileExt string `json:"fileExt"`
2026-09-15 16:37:39 +08:00
MimeType string `json:"mimeType"`
UploadedBy string `json:"uploadedBy"`
CreatedAt int64 `json:"createdAt"`
Archived bool `json:"archived"`
Deleted bool `json:"deleted"`
2026-09-15 16:37:39 +08:00
URL string `json:"url"`
}
func attachmentView(ctx *svc.ServiceContext, rec *ent.Attachment) *attachmentViewRow {
2026-09-15 16:37:39 +08:00
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),
2026-09-15 16:37:39 +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().
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 {
rows = append(rows, attachmentView(ctx, rec))
2026-09-15 16:37:39 +08:00
}
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,物理文件由归档任务统一处理)
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
}
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})
}
}
// serveUploadFileHandler 附件文件服务(按 id 提供,免鉴权便于 <img>/新窗口预览;支持 Range 断点续传)。
// GET /uploads/:namename = 附件 id,或历史扁平文件名兜底)
func serveUploadFileHandler(ctx *svc.ServiceContext) http.HandlerFunc {
2026-09-15 16:37:39 +08:00
return func(w http.ResponseWriter, r *http.Request) {
name := filepath.Base(r.URL.Path)
2026-09-15 16:37:39 +08:00
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 {
2026-09-15 16:37:39 +08:00
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)
2026-09-15 16:37:39 +08:00
}
}