feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新
1. 新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警 2. 重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑 3. 对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库 4. 实现入库单作废功能与区域库位的多级父子结构管理 5. 删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径 6. 新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
This commit is contained in:
@@ -1,43 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bj_power_mes/common/httpx"
|
||||
"bj_power_mes/ent"
|
||||
"bj_power_mes/internal/logic"
|
||||
"bj_power_mes/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/pathvar"
|
||||
)
|
||||
|
||||
// AttachmentUploadHandler POST /attachment/upload 上传并登记(multipart: bizType,bizId,file)
|
||||
// ---------- 附件(统一存储方案) ----------
|
||||
//
|
||||
// 落盘:<Upload.Dir>/年/月/日/<文件类型大写枚举>/<uuid>.<ext>
|
||||
// 库内:filePath 只存相对路径;下载/预览按 id 提供,避免中文与多级路径问题。
|
||||
|
||||
// attachmentRow 附件返回视图(前端友好:附可直接预览/下载的 URL)。
|
||||
type attachmentRow struct {
|
||||
ID int `json:"id"`
|
||||
BizType string `json:"bizType"`
|
||||
BizID string `json:"bizId"`
|
||||
FileType string `json:"fileType"`
|
||||
FileName string `json:"fileName"`
|
||||
FilePath string `json:"filePath"`
|
||||
FileSize int `json:"fileSize"`
|
||||
FileExt string `json:"fileExt"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Uploader string `json:"uploader"`
|
||||
Archived bool `json:"archived"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedBy string `json:"deletedBy"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
URL string `json:"url"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
}
|
||||
|
||||
func toAttachmentRow(a *ent.Attachment) *attachmentRow {
|
||||
// 预览走公开直链(文件名不可枚举:UUID + 日期目录),<img>/<iframe> 无法携带 JWT;
|
||||
// 下载走带鉴权的 id 路由,由服务端给出 RFC5987 中文文件名与 Content-Disposition。
|
||||
return &attachmentRow{
|
||||
ID: a.ID, BizType: a.BizType, BizID: a.BizId, FileType: a.FileType,
|
||||
FileName: a.FileName, FilePath: a.FilePath, FileSize: a.FileSize,
|
||||
FileExt: a.FileExt, MimeType: a.MimeType, Uploader: a.Uploader,
|
||||
Archived: a.Archived, Deleted: a.Deleted, DeletedBy: a.DeletedBy,
|
||||
CreatedAt: a.CreatedAt.Unix(),
|
||||
URL: "/api/v1/attachment/file/" + strconv.Itoa(a.ID) + "?dl=1",
|
||||
PreviewURL: previewURL(a.FilePath),
|
||||
}
|
||||
}
|
||||
|
||||
// previewURL 相对路径 → 公开免鉴权直链(复用 /files 路由,天然支持多级目录)。
|
||||
func previewURL(relPath string) string {
|
||||
if relPath == "" {
|
||||
return ""
|
||||
}
|
||||
return fileURL(relPath)
|
||||
}
|
||||
|
||||
// AttachmentUploadHandler POST /attachment/upload
|
||||
// multipart 字段:bizType(必填)、bizId(必填)、file(必填)、fileType(可选,大写枚举)
|
||||
// 流式落盘 + MD5 秒传(同业务对象同 MD5 直接返回既有记录,不重复存文件)。
|
||||
func AttachmentUploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
httpx.BadRequest(w, "表单解析失败")
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
|
||||
httpx.BadRequest(w, "表单解析失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
bizType := r.FormValue("bizType")
|
||||
bizId := r.FormValue("bizId")
|
||||
bizType := strings.TrimSpace(r.FormValue("bizType"))
|
||||
bizId := strings.TrimSpace(r.FormValue("bizId"))
|
||||
if bizType == "" || bizId == "" {
|
||||
httpx.BadRequest(w, "bizType 与 bizId 必填")
|
||||
return
|
||||
}
|
||||
rel, fname, size, err := saveUploadFileFull(r, "file", svcCtx.Config.Upload.Dir)
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3311, "文件保存失败:"+err.Error())
|
||||
return
|
||||
fileType := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
|
||||
dir := svcCtx.Config.Upload.Dir
|
||||
|
||||
// 先解析扩展名推断文件类型,再取对应大小上限
|
||||
if fileType == "" || !validFileTypes[fileType] {
|
||||
// 由 saveTypedUpload 内部按扩展名推断(此处先给空值)
|
||||
fileType = ""
|
||||
}
|
||||
rec, err := logic.New(svcCtx).AddAttachment(r.Context(), bizType, bizId, fname, rel, int(size), operator(r, ""))
|
||||
maxBytes := uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, fileType)
|
||||
res, err := saveTypedUpload(r, "file", dir, bizType, fileType, maxBytes)
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3311, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Ok(w, rec)
|
||||
|
||||
// MD5 秒传:同业务对象 + 同 MD5 已存在 → 直接返回既有记录(删除刚落盘文件)
|
||||
if exist, e := logic.New(svcCtx).FindByMD5(r.Context(), bizType, bizId, res.MD5); e == nil && exist != nil {
|
||||
if abs, ok := safeAbsPath(dir, res.RelPath); ok {
|
||||
_ = os.Remove(abs)
|
||||
}
|
||||
httpx.Ok(w, toAttachmentRow(exist))
|
||||
return
|
||||
}
|
||||
|
||||
uploader := operator(r, "")
|
||||
rec, err := logic.New(svcCtx).AddAttachment(r.Context(), bizType, bizId,
|
||||
res.RelPath2FileType(), res.FileName, res.RelPath, res.Ext, res.MD5, res.MimeType, int(res.Size), uploader)
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3311, "附件登记失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
svcCtx.EventLog.Write(r.Context(), "attachment.upload", "", uploader, "attachment", bizId,
|
||||
"上传附件 "+res.FileName, map[string]any{"bizType": bizType, "fileType": res.RelPath2FileType(), "size": res.Size})
|
||||
httpx.Ok(w, toAttachmentRow(rec))
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentListHandler GET /attachments?bizType=&bizId= 附件列表
|
||||
// AttachmentListHandler GET /attachments?bizType=&bizId= 某业务对象的附件列表
|
||||
func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
@@ -46,39 +126,202 @@ func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
httpx.Fail(w, 3312, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Ok(w, data)
|
||||
rows := make([]*attachmentRow, 0, len(data))
|
||||
for _, a := range data {
|
||||
rows = append(rows, toAttachmentRow(a))
|
||||
}
|
||||
httpx.Ok(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentDownloadHandler GET /attachment/download?name= 下载
|
||||
func AttachmentDownloadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
// AttachmentCenterHandler GET /attachment/all 附件中心(全量分页 + 磁盘状态)
|
||||
// 参数:fileType / bizType / bizId / fileName / startDate / endDate / includeDeleted / page / pageSize
|
||||
func AttachmentCenterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
httpx.BadRequest(w, "缺少 name")
|
||||
q := r.URL.Query()
|
||||
page := atoiDefault(q.Get("page"), 1)
|
||||
pageSize := atoiDefault(q.Get("pageSize"), 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
var start, end time.Time
|
||||
if v := strings.TrimSpace(q.Get("startDate")); v != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("endDate")); v != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||||
end = t.Add(24*time.Hour - time.Second)
|
||||
}
|
||||
}
|
||||
total, list, err := logic.New(svcCtx).ListAttachmentPage(r.Context(),
|
||||
strings.ToUpper(strings.TrimSpace(q.Get("fileType"))), strings.TrimSpace(q.Get("bizType")),
|
||||
strings.TrimSpace(q.Get("bizId")), strings.TrimSpace(q.Get("fileName")),
|
||||
start, end, q.Get("includeDeleted") == "true", page, pageSize)
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3312, err.Error())
|
||||
return
|
||||
}
|
||||
serveUploadFile(w, r, svcCtx.Config.Upload.Dir, name)
|
||||
rows := make([]*attachmentRow, 0, len(list))
|
||||
for _, a := range list {
|
||||
rows = append(rows, toAttachmentRow(a))
|
||||
}
|
||||
httpx.Ok(w, map[string]any{
|
||||
"total": total, "list": rows, "page": page, "pageSize": pageSize,
|
||||
"disk": diskStatus(svcCtx),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentDeleteHandler POST /attachment/delete {id} 删除(记录+物理文件)
|
||||
// AttachmentFileHandler GET /attachment/file/:id 下载/预览(支持 Range 断点续传;?dl=1 强制下载)
|
||||
func AttachmentFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := atoiDefault(pathvar.Vars(r)["id"], 0)
|
||||
if id <= 0 {
|
||||
httpx.BadRequest(w, "id 非法")
|
||||
return
|
||||
}
|
||||
rec, err := logic.New(svcCtx).GetAttachment(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
abs, ok := safeAbsPath(svcCtx.Config.Upload.Dir, rec.FilePath)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// 中文文件名走 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(rec.FileName)))
|
||||
http.ServeFile(w, r, abs) // ServeFile 自带 Range 支持
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentDeleteHandler POST /attachment/delete {id} 逻辑删除(库内标记,物理文件交由归档任务处理)
|
||||
func AttachmentDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Id int `json:"id"`
|
||||
}
|
||||
if err := httpx.ParseJSON(r, &req); err != nil {
|
||||
httpx.BadRequest(w, "请求体解析失败")
|
||||
if err := httpx.ParseJSON(r, &req); err != nil || req.Id <= 0 {
|
||||
httpx.BadRequest(w, "参数错误:id 必填")
|
||||
return
|
||||
}
|
||||
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id)
|
||||
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id, operator(r, ""))
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3313, err.Error())
|
||||
httpx.Fail(w, 3313, "删除附件失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
full := filepath.Join(svcCtx.Config.Upload.Dir, filepath.FromSlash(att.FilePath))
|
||||
_ = os.Remove(full)
|
||||
svcCtx.EventLog.Write(r.Context(), "attachment.delete", "", operator(r, ""), "attachment", att.BizId,
|
||||
"删除附件 "+att.FileName, nil)
|
||||
httpx.OkMessage(w, "删除成功", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentDiskStatusHandler GET /attachment/disk-status 磁盘剩余空间状态
|
||||
func AttachmentDiskStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Ok(w, diskStatus(svcCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentArchiveHandler POST /attachment/archive {dryRun} 归档
|
||||
// 把「早于本地保留年数」的附件移动到 ArchiveDir 并标记 archived=true(保留索引可追溯)。
|
||||
// ArchiveDir 为空时只标记不移动;dryRun=true 时仅返回将被归档的数量。
|
||||
func AttachmentArchiveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
_ = httpx.ParseJSON(r, &req)
|
||||
cfg := svcCtx.Config.Upload
|
||||
years := cfg.LocalRetentionYears
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
cutYear := time.Now().Year() - years // 早于该年份的附件可归档
|
||||
svc := logic.New(svcCtx)
|
||||
recs, err := svc.ListArchivableAttachments(r.Context())
|
||||
if err != nil {
|
||||
httpx.Fail(w, 3314, err.Error())
|
||||
return
|
||||
}
|
||||
moved, marked, failed := 0, 0, 0
|
||||
for _, rec := range recs {
|
||||
if rec.CreatedAt.Year() > cutYear-1 {
|
||||
continue
|
||||
}
|
||||
if req.DryRun {
|
||||
marked++
|
||||
continue
|
||||
}
|
||||
if cfg.ArchiveDir != "" {
|
||||
src, ok1 := safeAbsPath(cfg.Dir, rec.FilePath)
|
||||
dst, ok2 := safeAbsPath(cfg.ArchiveDir, rec.FilePath)
|
||||
if !ok1 || !ok2 {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
if err2 := copyFile(src, dst); err2 != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(src)
|
||||
}
|
||||
moved++
|
||||
}
|
||||
if err := svc.MarkArchived(r.Context(), rec.ID); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
marked++
|
||||
}
|
||||
if !req.DryRun {
|
||||
svcCtx.EventLog.Write(r.Context(), "attachment.archive", "", operator(r, ""), "attachment", "archive",
|
||||
fmt.Sprintf("附件归档:移动%d 标记%d 失败%d(早于 %d 年)", moved, marked, failed, cutYear),
|
||||
map[string]any{"moved": moved, "marked": marked, "failed": failed})
|
||||
}
|
||||
httpx.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 := out.ReadFrom(in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"bj_power_mes/common/httpx"
|
||||
"bj_power_mes/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// 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 / unknown
|
||||
Message string `json:"message"`
|
||||
CheckedAt int64 `json:"checkedAt"`
|
||||
}
|
||||
|
||||
// diskStatus 读取附件根目录所在磁盘剩余空间并给出告警级别。
|
||||
// 剩余 < DiskCriticalPercent → critical(提示执行备份归档清理);< DiskWarnPercent → warn。
|
||||
func diskStatus(svcCtx *svc.ServiceContext) diskStatusView {
|
||||
cfg := svcCtx.Config.Upload
|
||||
root := cfg.Dir
|
||||
if root == "" {
|
||||
root = "uploads"
|
||||
}
|
||||
v := diskStatusView{
|
||||
RootDir: root, WarnPercent: cfg.DiskWarnPercent, CritPercent: cfg.DiskCriticalPercent,
|
||||
Level: "ok", Message: "磁盘空间正常", CheckedAt: time.Now().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 /attachment/disk-status
|
||||
func DiskStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Ok(w, diskStatus(svcCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// StartDiskMonitor 启动磁盘空间监控:每天 DiskCheckHour(默认 7 点) 检测一次,
|
||||
// 剩余 <20% 告警、<10% 提示清理(写服务日志 + 操作日志,附件中心顶部同步展示)。
|
||||
func StartDiskMonitor(svcCtx *svc.ServiceContext) {
|
||||
go func() {
|
||||
hour := svcCtx.Config.Upload.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(svcCtx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func checkDiskOnce(svcCtx *svc.ServiceContext) {
|
||||
st := diskStatus(svcCtx)
|
||||
switch st.Level {
|
||||
case "critical":
|
||||
logx.Errorf("[磁盘监控] %s(已用 %d%%,剩余 %d%%)", st.Message, st.UsedPercent, 100-st.UsedPercent)
|
||||
svcCtx.EventLog.Write(context.Background(), "system.disk", "", "system", "attachment", "disk", st.Message,
|
||||
map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
|
||||
case "warn":
|
||||
logx.Alert("[磁盘监控] " + st.Message)
|
||||
svcCtx.EventLog.Write(context.Background(), "system.disk", "", "system", "attachment", "disk", st.Message,
|
||||
map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
|
||||
default:
|
||||
logx.Infof("[磁盘监控] 正常:剩余 %d%%(%s)", 100-st.UsedPercent, st.RootDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package handler
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// diskUsage 返回指定目录所在磁盘的已用百分比(0-100)、可用字节、总字节。
|
||||
func diskUsage(path string) (usedPercent int, freeBytes, totalBytes uint64, err error) {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
if total == 0 {
|
||||
return 0, free, total, nil
|
||||
}
|
||||
return int((total - free) * 100 / total), free, total, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package handler
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// diskUsage 返回指定目录所在磁盘的已用百分比(0-100)、可用字节、总字节。
|
||||
func diskUsage(path string) (usedPercent int, freeBytes, totalBytes uint64, err error) {
|
||||
p, e := windows.UTF16PtrFromString(path)
|
||||
if e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
var freeAvail, total, totalFree uint64
|
||||
if e := windows.GetDiskFreeSpaceEx(p, &freeAvail, &total, &totalFree); e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, totalFree, total, nil
|
||||
}
|
||||
used := total - totalFree
|
||||
return int(used * 100 / total), totalFree, total, nil
|
||||
}
|
||||
@@ -71,19 +71,21 @@ func ListInspectionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// UploadPhotoHandler POST /inspections/upload(巡检拍照上传,返回文件名)
|
||||
// UploadPhotoHandler POST /inspections/upload(巡检/异常拍照上传)
|
||||
// 统一存储:<Upload.Dir>/年/月/日/INSPECTION_PHOTO/<uuid>.<ext>;返回相对路径 + 免鉴权直链。
|
||||
func UploadPhotoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(svcCtx.Config.Upload.MaxMB << 20); err != nil {
|
||||
httpx.BadRequest(w, "上传文件过大或格式错误")
|
||||
return
|
||||
}
|
||||
name, err := saveUploadFile(r, "file", svcCtx.Config.Upload.Dir)
|
||||
res, err := saveTypedUpload(r, "file", svcCtx.Config.Upload.Dir, "inspection", FileTypeInspectionPhoto,
|
||||
uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, FileTypeInspectionPhoto))
|
||||
if err != nil {
|
||||
httpx.Fail(w, 2303, "照片保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Ok(w, map[string]any{"filename": name, "url": "/api/v1/files/" + name})
|
||||
httpx.Ok(w, map[string]any{"filename": res.RelPath, "url": "/api/v1/files/" + res.RelPath})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,13 @@ func UploadPdfHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
httpx.BadRequest(w, "上传文件过大或格式错误")
|
||||
return
|
||||
}
|
||||
name, err := saveUploadFile(r, "file", svcCtx.Config.Upload.Dir)
|
||||
res, err := saveTypedUpload(r, "file", svcCtx.Config.Upload.Dir, "process_flow", FileTypeProcessPDF,
|
||||
uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, FileTypeProcessPDF))
|
||||
if err != nil {
|
||||
httpx.Fail(w, 2204, "文件保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Ok(w, map[string]any{"filename": name, "url": "/api/v1/files/" + name})
|
||||
httpx.Ok(w, map[string]any{"filename": res.RelPath, "url": "/api/v1/files/" + res.RelPath})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +85,16 @@ func FilePathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// FileDeepPathHandler GET /files/:y/:m/:d/:t/:f —— 统一存储格式的路径式 URL
|
||||
// (年/月/日/文件类型/uuid.ext)。go-zero 路由不支持多段通配,故按固定 5 段显式声明。
|
||||
func FileDeepPathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := pathvar.Vars(r)
|
||||
rel := vars["y"] + "/" + vars["m"] + "/" + vars["d"] + "/" + vars["t"] + "/" + vars["f"]
|
||||
serveUploadFile(w, r, svcCtx.Config.Upload.Dir, rel)
|
||||
}
|
||||
}
|
||||
|
||||
// ListStationsHandler GET /stations
|
||||
func ListStationsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -212,10 +212,13 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
|
||||
{Method: http.MethodGet, Path: "/alerts", Handler: production.ListAlertsHandler(serverCtx)},
|
||||
{Method: http.MethodPost, Path: "/alert/read", Handler: production.MarkAlertReadHandler(serverCtx)},
|
||||
|
||||
// ---------- 附件中心(M10) ----------
|
||||
// ---------- 附件中心(统一存储方案 2026-09-19) ----------
|
||||
{Method: http.MethodPost, Path: "/attachment/upload", Handler: AttachmentUploadHandler(serverCtx)},
|
||||
{Method: http.MethodGet, Path: "/attachments", Handler: AttachmentListHandler(serverCtx)},
|
||||
{Method: http.MethodGet, Path: "/attachment/download", Handler: AttachmentDownloadHandler(serverCtx)},
|
||||
{Method: http.MethodGet, Path: "/attachment/all", Handler: AttachmentCenterHandler(serverCtx)},
|
||||
{Method: http.MethodGet, Path: "/attachment/file/:id", Handler: AttachmentFileHandler(serverCtx)},
|
||||
{Method: http.MethodGet, Path: "/attachment/disk-status", Handler: DiskStatusHandler(serverCtx)},
|
||||
{Method: http.MethodPost, Path: "/attachment/archive", Handler: AttachmentArchiveHandler(serverCtx)},
|
||||
{Method: http.MethodPost, Path: "/attachment/delete", Handler: AttachmentDeleteHandler(serverCtx)},
|
||||
|
||||
// ---------- 工艺流程/工位(块3) ----------
|
||||
@@ -266,8 +269,9 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
|
||||
rest.WithMiddlewares([]rest.Middleware{permissionGuard(serverCtx)}, jwtRoutes...),
|
||||
rest.WithPrefix("/api/v1"),
|
||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||
// 请求体上限跟随 Upload.MaxMB(默认20MB),否则 go-zero 默认 1MB 会 413(PDF/照片上传在此分组)
|
||||
rest.WithMaxBytes(serverCtx.Config.Upload.MaxMB<<20),
|
||||
// 请求体上限取「Upload.MaxMB 与各文件类型 MaxSize 的最大值」,否则 go-zero 默认 1MB 会 413
|
||||
// (PDF/照片上传在此分组;大写枚举若配了 50MB,此处必须放宽到 50MB 才不会被网关先拦)
|
||||
rest.WithMaxBytes(maxUploadBytes(serverCtx.Config.Upload.MaxMB, serverCtx.Config.Upload.MaxSize)),
|
||||
)
|
||||
|
||||
// ---------- 公开文件访问(免 JWT) ----------
|
||||
|
||||
@@ -1,69 +1,251 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// saveUploadFile 保存上传文件(工艺图纸PDF / 巡检照片),返回生成的相对文件名(含日期子目录)
|
||||
func saveUploadFile(r *http.Request, field, dir string) (string, error) {
|
||||
sub := time.Now().Format("2006-01-02")
|
||||
full := filepath.Join(dir, sub)
|
||||
if err := os.MkdirAll(full, 0o755); err != nil {
|
||||
return "", err
|
||||
// ---------------- 附件统一存储方案(2026-09-19) ----------------
|
||||
//
|
||||
// 目录结构:<Upload.Dir>/年/月/日/<文件类型>/<uuid>.<ext>
|
||||
// - 年月日按当前日期自动生成(月/日补零)
|
||||
// - 业务类型目录全部大写(见下方枚举)
|
||||
// - 文件名统一 UUID,扩展名小写,不含中文/空格/特殊字符
|
||||
// - 数据库只存相对路径(如 2026/09/19/PROCESS_PDF/xxx.pdf),不存绝对路径
|
||||
// - 根目录从 etc/*.yaml 的 Upload.Dir 读取,不硬编码
|
||||
|
||||
// 文件类型枚举(全部大写)
|
||||
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,
|
||||
}
|
||||
|
||||
// allowedExt 允许的扩展名白名单(图纸/报告/图片/表格/压缩包)。白名单而非黑名单,避免上传可执行文件。
|
||||
var allowedExt = 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,
|
||||
}
|
||||
|
||||
// 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 == "work_order":
|
||||
if ext == ".pdf" {
|
||||
return FileTypeProcessCard
|
||||
}
|
||||
case bizType == "process_flow" && 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
|
||||
}
|
||||
|
||||
// newUUID 生成 RFC4122 v4 UUID(crypto/rand,不引第三方依赖)。
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// UploadResult 上传落盘结果。
|
||||
type UploadResult struct {
|
||||
RelPath string // 相对路径:年/月/日/文件类型/uuid.ext
|
||||
FileName string // 原始文件名(含扩展名)
|
||||
Size int64
|
||||
MD5 string
|
||||
Ext string // 小写,不含点
|
||||
MimeType string
|
||||
}
|
||||
|
||||
// RelPath2FileType 从相对路径反推文件类型目录名(年/月/日/类型/uuid.ext → 类型)。
|
||||
// 供处理器在落盘后回填库内 fileType(避免再解析一次文件名)。
|
||||
func (u *UploadResult) RelPath2FileType() string {
|
||||
parts := strings.Split(u.RelPath, "/")
|
||||
if len(parts) >= 2 {
|
||||
if ft := parts[len(parts)-2]; validFileTypes[ft] {
|
||||
return ft
|
||||
}
|
||||
}
|
||||
return FileTypeOther
|
||||
}
|
||||
|
||||
// saveTypedUpload 流式保存上传文件(边读边算 MD5,内存占用恒定;绝不 ReadAll)。
|
||||
// fileType 为空或非法时按 bizType+扩展名推断;maxBytes<=0 时用 Upload.MaxMB。
|
||||
// 返回相对路径(年/月/日/文件类型/uuid.ext)与原始文件名/大小/MD5。
|
||||
func saveTypedUpload(r *http.Request, field, rootDir, bizType, fileType string, maxBytes int64) (*UploadResult, error) {
|
||||
file, header, err := r.FormFile(field)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
if ext == "" || !allowedExt[ext] {
|
||||
return nil, fmt.Errorf("不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)")
|
||||
}
|
||||
name := time.Now().Format("20060102150405") + "_" + randHex(6) + ext
|
||||
dst, err := os.Create(filepath.Join(full, name))
|
||||
if fileType == "" || !validFileTypes[strings.ToUpper(fileType)] {
|
||||
fileType = deriveFileType(bizType, ext)
|
||||
} else {
|
||||
fileType = strings.ToUpper(fileType)
|
||||
}
|
||||
if maxBytes > 0 && header.Size > 0 && header.Size > maxBytes {
|
||||
return nil, fmt.Errorf("文件超过 %dMB 上限,请压缩后重传", maxBytes>>20)
|
||||
}
|
||||
|
||||
relDir := buildRelDir(fileType, time.Now())
|
||||
absDir, ok := safeAbsPath(rootDir, relDir)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("非法存储路径")
|
||||
}
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storedName := newUUID() + ext
|
||||
relPath := relDir + "/" + storedName
|
||||
dst := filepath.Join(absDir, storedName)
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
return "", err
|
||||
hash := md5.New()
|
||||
var written int64
|
||||
if maxBytes > 0 {
|
||||
written, err = io.Copy(io.MultiWriter(out, hash), io.LimitReader(file, maxBytes+1))
|
||||
} else {
|
||||
written, err = io.Copy(io.MultiWriter(out, hash), file)
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(sub, name)), nil
|
||||
_ = out.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes > 0 && written > maxBytes {
|
||||
_ = os.Remove(dst)
|
||||
return nil, fmt.Errorf("文件超过 %dMB 上限,请压缩后重传", maxBytes>>20)
|
||||
}
|
||||
|
||||
return &UploadResult{
|
||||
RelPath: relPath,
|
||||
FileName: header.Filename,
|
||||
Size: written,
|
||||
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)[:n*2]
|
||||
// uploadMaxBytes 单文件上限:按文件类型取 Upload.MaxSize 配置,未配置用 Upload.MaxMB。
|
||||
func uploadMaxBytes(maxSizes map[string]int64, maxMB int64, fileType string) int64 {
|
||||
if maxSizes != nil {
|
||||
if v, ok := maxSizes[strings.ToUpper(fileType)]; ok && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if maxMB > 0 {
|
||||
return maxMB << 20
|
||||
}
|
||||
return 20 << 20
|
||||
}
|
||||
|
||||
// serveUploadFile 下发上传目录中的文件(仅允许 单层日期子目录/文件名,防路径穿越)
|
||||
// maxUploadBytes 计算路由组请求体上限:取 MaxMB 与各文件类型 MaxSize 的最大值(兜底 20MB)。
|
||||
func maxUploadBytes(maxMB int64, maxSizes map[string]int64) int64 {
|
||||
limit := maxMB << 20
|
||||
if limit <= 0 {
|
||||
limit = 20 << 20
|
||||
}
|
||||
for _, v := range maxSizes {
|
||||
if v > limit {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// fileURL 由相对路径生成免鉴权直链(query 传名,天然支持多级目录)。
|
||||
// 注:go-zero 路由不支持多段通配,故统一走 /api/v1/files?name=<urlencoded 相对路径>。
|
||||
func fileURL(relPath string) string {
|
||||
return "/api/v1/files?name=" + url.QueryEscape(relPath)
|
||||
}
|
||||
|
||||
// serveUploadFile 下发上传目录中的文件(相对路径可为多级「年/月/日/类型/文件名」,防路径穿越)。
|
||||
func serveUploadFile(w http.ResponseWriter, r *http.Request, dir, name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if clean == "." || clean == ".." ||
|
||||
strings.HasPrefix(clean, ".."+string(filepath.Separator)) ||
|
||||
filepath.IsAbs(clean) || strings.ContainsRune(name, '\x00') {
|
||||
if name == "" {
|
||||
http.Error(w, "非法文件名", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(filepath.Join(dir, clean))
|
||||
abs, ok := safeAbsPath(dir, name)
|
||||
if !ok {
|
||||
http.Error(w, "非法文件名", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
http.Error(w, "文件不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
ct := "application/octet-stream"
|
||||
base := strings.ToLower(clean)
|
||||
base := strings.ToLower(abs)
|
||||
switch {
|
||||
case strings.HasSuffix(base, ".pdf"):
|
||||
ct = "application/pdf"
|
||||
@@ -71,35 +253,20 @@ func serveUploadFile(w http.ResponseWriter, r *http.Request, dir, name string) {
|
||||
ct = "image/jpeg"
|
||||
case strings.HasSuffix(base, ".png"):
|
||||
ct = "image/png"
|
||||
case strings.HasSuffix(base, ".gif"):
|
||||
ct = "image/gif"
|
||||
case strings.HasSuffix(base, ".xlsx"):
|
||||
ct = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case strings.HasSuffix(base, ".xls"):
|
||||
ct = "application/vnd.ms-excel"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
http.ServeContent(w, r, name, time.Time{}, f)
|
||||
http.ServeContent(w, r, filepath.Base(abs), time.Time{}, f)
|
||||
}
|
||||
|
||||
// saveUploadFileFull 同 saveUploadFile,但额外返回原始文件名与字节大小(附件登记用)
|
||||
func saveUploadFileFull(r *http.Request, field, dir string) (string, string, int64, error) {
|
||||
sub := time.Now().Format("2006-01-02")
|
||||
full := filepath.Join(dir, sub)
|
||||
if err := os.MkdirAll(full, 0o755); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
file, header, err := r.FormFile(field)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
name := time.Now().Format("20060102150405") + "_" + randHex(6) + ext
|
||||
dst, err := os.Create(filepath.Join(full, name))
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(sub, name)), header.Filename, header.Size, nil
|
||||
// randHex 随机十六进制串(保留给其他场景的短随机名使用)。
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)[:n*2]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user