Files
qitongxue-api/internal/logic/attachment.go
2026-09-30 17:01:18 +08:00

166 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}