166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gfile"
|
||
|
||
adminv1 "tool-api/api/admin/v1"
|
||
"tool-api/internal/consts"
|
||
)
|
||
|
||
// ============================================================================
|
||
// 附件管理(迭代-2026-09-29)
|
||
//
|
||
// 上传统一登记 attachments 表(头像/封面/通用附件都在 recordAttachment 落账),
|
||
// 本文件提供后台的「查看 / 删除」。删除语义:
|
||
// - 记录一律软删(deleted_at,保留审计线索);
|
||
// - 文件本体只在「无任何引用」时才落盘删除(md5 去重意味着同一文件可能被
|
||
// 多条记录 / 用户头像 / 服务封面共享,盲删会把这些引用全部打断)。
|
||
// ============================================================================
|
||
|
||
var imageExtSet = map[string]bool{
|
||
".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
|
||
}
|
||
|
||
// AdminAttachmentList 附件列表(上传日期倒序)
|
||
func AdminAttachmentList(ctx context.Context, req *adminv1.AdminAttachmentListReq) (*adminv1.AdminAttachmentListRes, error) {
|
||
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
|
||
m := g.Model(consts.TableAttachments)
|
||
if kw := strings.TrimSpace(req.Keyword); kw != "" {
|
||
m = m.Where("file_name LIKE ?", "%"+kw+"%")
|
||
}
|
||
if scene := strings.TrimSpace(req.Scene); scene != "" {
|
||
m = m.Where("scene", scene)
|
||
}
|
||
total, err := m.Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
records, err := m.Page(page, pageSize).OrderDesc("id").All()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 批量做引用检查(避免逐行查询):同路径的其他未删记录 / 用户头像 / 服务封面
|
||
paths := make([]string, 0, len(records))
|
||
for _, r := range records {
|
||
paths = append(paths, r["file_path"].String())
|
||
}
|
||
refMap := attachmentRefCounts(ctx, paths)
|
||
list := make([]adminv1.AdminAttachmentItem, 0, len(records))
|
||
for _, r := range records {
|
||
path := r["file_path"].String()
|
||
ext := r["ext"].String()
|
||
list = append(list, adminv1.AdminAttachmentItem{
|
||
Id: r["id"].Int64(),
|
||
Scene: r["scene"].String(),
|
||
FileName: r["file_name"].String(),
|
||
FilePath: path,
|
||
Url: absoluteURL(ctx, path),
|
||
Ext: ext,
|
||
SizeBytes: r["size_bytes"].Int64(),
|
||
Md5: r["md5"].String(),
|
||
UploaderId: r["uploader_id"].Int64(),
|
||
UploaderType: r["uploader_type"].String(),
|
||
// 计数含本记录自身(路径至少被自己引用)→ >1 才代表还有其他引用方;
|
||
// 删除流程的检查发生在软删之后,语义见 AdminAttachmentDelete
|
||
IsReferenced: refMap[path] > 1,
|
||
IsImage: imageExtSet[ext],
|
||
CreatedAt: timeStr(r["created_at"].GTime()),
|
||
})
|
||
}
|
||
return &adminv1.AdminAttachmentListRes{List: list, Total: total}, nil
|
||
}
|
||
|
||
// AdminAttachmentDelete 删除附件:记录软删;文件本体无引用时落盘删除。
|
||
func AdminAttachmentDelete(ctx context.Context, id int64) (*adminv1.AdminAttachmentDeleteRes, error) {
|
||
r, err := g.Model(consts.TableAttachments).Where("id", id).One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.IsEmpty() {
|
||
return nil, gerror.New("附件不存在或已删除")
|
||
}
|
||
path := r["file_path"].String()
|
||
if _, err = g.Model(consts.TableAttachments).Where("id", id).Data(g.Map{
|
||
"deleted_at": gdb.Raw("NOW()"),
|
||
}).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
res := &adminv1.AdminAttachmentDeleteRes{}
|
||
// 引用检查在软删之后:排除本记录自身,剩余引用 >0 则保留文件本体
|
||
if refs := attachmentRefCounts(ctx, []string{path}); refs[path] == 0 {
|
||
if disk, ok := staticPathToDisk(path); ok && gfile.Exists(disk) {
|
||
if err = gfile.Remove(disk); err != nil {
|
||
// 文件删除失败不回滚记录删除:记录已隐藏,文件成为孤儿可人工清理
|
||
g.Log().Warningf(ctx, "[attachment] 删除文件失败 path=%s err=%v", path, err)
|
||
} else {
|
||
res.RemovedFile = true
|
||
}
|
||
} else {
|
||
// 文件本就不在(历史遗留/多实例部署),记录删除即达成目的
|
||
res.RemovedFile = true
|
||
}
|
||
}
|
||
WriteAudit(ctx, AuditEntry{
|
||
Action: "attachment.delete",
|
||
TargetType: "attachment",
|
||
TargetId: auditId(id),
|
||
Before: g.Map{"file_path": path, "file_name": r["file_name"].String()},
|
||
Result: consts.AuditResultSuccess,
|
||
})
|
||
return res, nil
|
||
}
|
||
|
||
// attachmentRefCounts 统计每个路径的「存活引用数」:
|
||
// 其他未删附件记录(md5 去重共享同一落盘文件)+ 用户头像(存量含绝对 URL,用 LIKE 收敛)+ 服务封面。
|
||
func attachmentRefCounts(ctx context.Context, paths []string) map[string]int {
|
||
out := make(map[string]int, len(paths))
|
||
if len(paths) == 0 {
|
||
return out
|
||
}
|
||
for _, p := range paths {
|
||
if p != "" {
|
||
out[p] = 0
|
||
}
|
||
}
|
||
// 同路径的其他未删附件记录
|
||
records, err := g.Model(consts.TableAttachments).
|
||
WhereIn("file_path", paths).
|
||
WhereNull("deleted_at").
|
||
Fields("file_path, COUNT(*) AS n").
|
||
Group("file_path").
|
||
All()
|
||
if err == nil {
|
||
for _, r := range records {
|
||
out[r["file_path"].String()] += r["n"].Int()
|
||
}
|
||
} else {
|
||
g.Log().Warningf(ctx, "[attachment] 引用检查(附件)失败: %v", err)
|
||
}
|
||
// 用户头像:LIKE '%path%' 兼容存量绝对 URL 写法
|
||
for _, p := range paths {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
n, err := g.Model(consts.TableUsers).Where("avatar_url LIKE ?", "%"+p+"%").WhereNull("deleted_at").Count()
|
||
if err == nil && n > 0 {
|
||
out[p] += n
|
||
} else if err != nil {
|
||
g.Log().Warningf(ctx, "[attachment] 引用检查(头像)失败: %v", err)
|
||
}
|
||
// 服务封面
|
||
n, err = g.Model(consts.TableGoods).Where("cover_path", p).WhereNull("deleted_at").Count()
|
||
if err == nil && n > 0 {
|
||
out[p] += n
|
||
} else if err != nil {
|
||
g.Log().Warningf(ctx, "[attachment] 引用检查(封面)失败: %v", err)
|
||
}
|
||
}
|
||
return out
|
||
}
|