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

328 lines
11 KiB
Go
Raw Normal View History

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"
)
// ---------- 附件(统一存储方案) ----------
//
// 落盘:<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(4 << 20); err != nil && err != http.ErrNotMultipart {
httpx.BadRequest(w, "表单解析失败:"+err.Error())
return
}
bizType := strings.TrimSpace(r.FormValue("bizType"))
bizId := strings.TrimSpace(r.FormValue("bizId"))
if bizType == "" || bizId == "" {
httpx.BadRequest(w, "bizType 与 bizId 必填")
return
}
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)
if err != nil {
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))
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= 某业务对象的附件列表
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
}
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)})
}
}
// 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) {
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
}
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),
})
}
}
// 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 || req.Id <= 0 {
httpx.BadRequest(w, "参数错误:id 必填")
return
}
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id, operator(r, ""))
if err != nil {
httpx.Fail(w, 3313, "删除附件失败:"+err.Error())
return
}
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()
}