2026-09-14 16:31:08 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"fmt"
|
2026-09-14 16:31:08 +08:00
|
|
|
|
"net/http"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"net/url"
|
2026-09-14 16:31:08 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-09-14 16:31:08 +08:00
|
|
|
|
|
|
|
|
|
|
"bj_power_mes/common/httpx"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
"bj_power_mes/ent"
|
2026-09-14 16:31:08 +08:00
|
|
|
|
"bj_power_mes/internal/logic"
|
|
|
|
|
|
"bj_power_mes/internal/svc"
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/zeromicro/go-zero/rest/pathvar"
|
2026-09-14 16:31:08 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// ---------- 附件(统一存储方案) ----------
|
|
|
|
|
|
//
|
|
|
|
|
|
// 落盘:<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 直接返回既有记录,不重复存文件)。
|
2026-09-14 16:31:08 +08:00
|
|
|
|
func AttachmentUploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
|
|
|
|
|
|
httpx.BadRequest(w, "表单解析失败:"+err.Error())
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
bizType := strings.TrimSpace(r.FormValue("bizType"))
|
|
|
|
|
|
bizId := strings.TrimSpace(r.FormValue("bizId"))
|
2026-09-14 16:31:08 +08:00
|
|
|
|
if bizType == "" || bizId == "" {
|
|
|
|
|
|
httpx.BadRequest(w, "bizType 与 bizId 必填")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
fileType := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
|
|
|
|
|
|
dir := svcCtx.Config.Upload.Dir
|
|
|
|
|
|
|
|
|
|
|
|
// 先解析扩展名推断文件类型,再取对应大小上限
|
|
|
|
|
|
if fileType == "" || !validFileTypes[fileType] {
|
|
|
|
|
|
// 由 saveTypedUpload 内部按扩展名推断(此处先给空值)
|
|
|
|
|
|
fileType = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
maxBytes := uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, fileType)
|
|
|
|
|
|
res, err := saveTypedUpload(r, "file", dir, bizType, fileType, maxBytes)
|
2026-09-14 16:31:08 +08:00
|
|
|
|
if err != nil {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
httpx.Fail(w, 3311, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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))
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-14 16:31:08 +08:00
|
|
|
|
if err != nil {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
httpx.Fail(w, 3311, "附件登记失败:"+err.Error())
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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))
|
2026-09-14 16:31:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// AttachmentListHandler GET /attachments?bizType=&bizId= 某业务对象的附件列表
|
2026-09-14 16:31:08 +08:00
|
|
|
|
func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
q := r.URL.Query()
|
|
|
|
|
|
data, err := logic.New(svcCtx).ListAttachments(r.Context(), q.Get("bizType"), q.Get("bizId"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
httpx.Fail(w, 3312, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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)})
|
2026-09-14 16:31:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// AttachmentCenterHandler GET /attachment/all 附件中心(全量分页 + 磁盘状态)
|
|
|
|
|
|
// 参数:fileType / bizType / bizId / fileName / startDate / endDate / includeDeleted / page / pageSize
|
|
|
|
|
|
func AttachmentCenterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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())
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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),
|
|
|
|
|
|
})
|
2026-09-14 16:31:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// 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} 逻辑删除(库内标记,物理文件交由归档任务处理)
|
2026-09-14 16:31:08 +08:00
|
|
|
|
func AttachmentDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
Id int `json:"id"`
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
if err := httpx.ParseJSON(r, &req); err != nil || req.Id <= 0 {
|
|
|
|
|
|
httpx.BadRequest(w, "参数错误:id 必填")
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id, operator(r, ""))
|
2026-09-14 16:31:08 +08:00
|
|
|
|
if err != nil {
|
2026-09-19 10:54:15 +08:00
|
|
|
|
httpx.Fail(w, 3313, "删除附件失败:"+err.Error())
|
2026-09-14 16:31:08 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
svcCtx.EventLog.Write(r.Context(), "attachment.delete", "", operator(r, ""), "attachment", att.BizId,
|
|
|
|
|
|
"删除附件 "+att.FileName, nil)
|
2026-09-14 16:31:08 +08:00
|
|
|
|
httpx.OkMessage(w, "删除成功", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
}
|