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 }