feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:
**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
`POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
`deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。
**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。
**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
必须自己补校验(库层没有复合外键)。
**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。
测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
This commit is contained in:
1 parent
631c8dd8e1
commit
53a0cb225c
61 files changed
+6386
-699
No files matched your search
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -1294,3 +1295,292 @@ func uploadContentType(name string) (string, bool) {
|
||||
ctype, ok := allowedUploadImageTypes[ext]
|
||||
return ctype, ok
|
||||
}
|
||||
|
||||
// ---------- 上传图片的删除与孤儿清理 ----------
|
||||
|
||||
// uploadRef 是一条「引用了某张上传图片」的配置:name 是配置值指向的文件名,label 是给操作员看的位置。
|
||||
type uploadRef struct {
|
||||
name string
|
||||
label string
|
||||
}
|
||||
|
||||
// uploadRefPoints 是赛事设置里的图片引用点。
|
||||
// 加新的图片来源时必须同步加到这里(以及 uploadRefs 里的单位查询),
|
||||
// 否则孤儿清单会把正在用的图片当成垃圾删掉。
|
||||
var uploadRefPoints = []struct {
|
||||
key string
|
||||
label string
|
||||
}{
|
||||
{"event.logo_path", "赛事信息·赛事会标"},
|
||||
{"display.logo", "赛事信息·大屏会标"},
|
||||
{"display.background", "赛事信息·大屏背景图"},
|
||||
}
|
||||
|
||||
// uploadRefs 扫一遍全部引用点,返回「哪张图被哪里引用」。
|
||||
//
|
||||
// 单位不按 deleted_at 过滤:被彻底删除(隐藏)的单位还能恢复,恢复后图标必须还在,
|
||||
// 所以它的 logo 仍然算被引用——这里的方向是宁可漏删孤儿,也不能误删在用的图。
|
||||
func (s *server) uploadRefs(ctx context.Context) ([]uploadRef, error) {
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs []uploadRef
|
||||
for _, point := range uploadRefPoints {
|
||||
if name, ok := uploadNameInPath(values[point.key]); ok {
|
||||
refs = append(refs, uploadRef{name: name, label: point.label})
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT COALESCE(code, ''), name, COALESCE(short_name, ''), COALESCE(logo_path, '')
|
||||
FROM teams WHERE COALESCE(logo_path, '') <> ''`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var code, teamName, shortName, logoPath string
|
||||
if err := rows.Scan(&code, &teamName, &shortName, &logoPath); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
name, ok := uploadNameInPath(logoPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label := firstNonBlank(teamName, shortName, code)
|
||||
refs = append(refs, uploadRef{name: name, label: "单位「" + label + "」的单位图标"})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// referencedUploads 把 uploadRefs 汇总成 文件名 → 引用位置列表。
|
||||
// 孤儿清单与删除前的复核共用它,保证「列出来是孤儿」与「删的时候判定还是孤儿」口径一致。
|
||||
func (s *server) referencedUploads(ctx context.Context) (map[string][]string, error) {
|
||||
refs, err := s.uploadRefs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string][]string, len(refs))
|
||||
for _, ref := range refs {
|
||||
out[ref.name] = append(out[ref.name], ref.label)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadNameInPath 从配置值里取出它指向的上传文件名。
|
||||
//
|
||||
// 管理端允许手填 `uploads/xxx.png` 这类相对路径,也可能粘一整条地址,
|
||||
// 所以统一去掉查询串后取最后一段路径再比对。这种「宽松识别」的方向是有意的:
|
||||
// 认错(把别处同名的图当成本地文件)最多漏删一个孤儿,不认则会删掉正在用的图。
|
||||
func uploadNameInPath(value string) (string, bool) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
// Windows 手填路径用反斜杠,先归一成 /。
|
||||
v = strings.ReplaceAll(v, `\`, "/")
|
||||
if i := strings.LastIndexAny(v, "?#"); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
v = strings.TrimSuffix(v, "/")
|
||||
if i := strings.LastIndex(v, "/"); i >= 0 {
|
||||
v = v[i+1:]
|
||||
}
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// uploadFileDTO 是孤儿清单里的一项。
|
||||
type uploadFileDTO struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type uploadOrphansDTO struct {
|
||||
Items []uploadFileDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
}
|
||||
|
||||
// listUploadOrphansHandler 列出 uploads/ 下没有被任何引用点用到的图片(admin)。
|
||||
//
|
||||
// 只扫单层目录:uploads/fonts/ 是字体资源,有各自的删除接口,不归这里管。
|
||||
// 目录不存在回空列表——「一张孤儿都没有」是正常状态,不是错误。
|
||||
func (s *server) listUploadOrphansHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
referenced, err := s.referencedUploads(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return uploadOrphansDTO{Items: []uploadFileDTO{}}, nil
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
items := make([]uploadFileDTO, 0, len(entries))
|
||||
var totalBytes int64
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
// 复用静态访问的校验:清单里不该出现 /uploads/ 根本取不到的文件。
|
||||
if _, ok := uploadContentType(name); !ok {
|
||||
continue
|
||||
}
|
||||
if _, used := referenced[name]; used {
|
||||
continue
|
||||
}
|
||||
// 单个条目读不到(权限 / 并发删除)只跳过它,不让整张清单失败。
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, uploadFileDTO{
|
||||
Name: name,
|
||||
URL: "/uploads/" + name,
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
totalBytes += info.Size()
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||
return uploadOrphansDTO{Items: items, Total: len(items), TotalBytes: totalBytes}, nil
|
||||
}
|
||||
|
||||
// deleteUploadHandler 删除单张上传图片(admin)。
|
||||
//
|
||||
// 仍被赛事设置或单位引用时拒绝,并把「被谁引用」列出来:只回一句「正在使用」
|
||||
// 会让现场不知道该去哪里改,而删掉在用的图会让大屏直接白图,比拒绝更难排查
|
||||
// (与字体删除同一口径,见 fonts.go 的 ensureFontUnused)。
|
||||
func (s *server) deleteUploadHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
name := r.PathValue("name")
|
||||
// 文件名收敛与防穿越复用静态访问的校验,不另写一套。
|
||||
if _, ok := uploadContentType(name); !ok {
|
||||
return notFound("图片不存在")
|
||||
}
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
referenced, err := s.referencedUploads(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refs := referenced[name]; len(refs) > 0 {
|
||||
return conflict(uploadInUseMessage(name, refs))
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return notFound("图片不存在")
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"file": name}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadInUseMessage 把「被谁引用」拼成操作员看得懂的拒绝理由。
|
||||
func uploadInUseMessage(name string, refs []string) string {
|
||||
return "图片 " + name + " 正在被使用(" + strings.Join(refs, "、") +
|
||||
"),请先在对应位置改掉或清除后再删除"
|
||||
}
|
||||
|
||||
// maxUploadDeleteBatch 限制一次批量删除的条目数,超出的请求整单拒绝而不是部分执行。
|
||||
const maxUploadDeleteBatch = 500
|
||||
|
||||
type deleteUploadsInput struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
type uploadDeleteFailure struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type deleteUploadsResult struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Failed []uploadDeleteFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// deleteUploadsHandler 批量删除「请求里明确列出」的孤儿图片(admin),逐条回报结果。
|
||||
//
|
||||
// 红线:只动请求点名的文件,绝不「扫到没引用的就删」。清单在前端可能已经过期
|
||||
// (期间有人把图配到了单位或背景上),所以删除前重新扫一遍引用,被引用的条目只回报不删。
|
||||
func (s *server) deleteUploadsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in deleteUploadsInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(in.Names) == 0 {
|
||||
return nil, badRequest("没有指定要删除的图片", nil)
|
||||
}
|
||||
if len(in.Names) > maxUploadDeleteBatch {
|
||||
return nil, badRequest(fmt.Sprintf("一次最多删除 %d 张图片", maxUploadDeleteBatch), len(in.Names))
|
||||
}
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
referenced, err := s.referencedUploads(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deleted := make([]string, 0, len(in.Names))
|
||||
failed := make([]uploadDeleteFailure, 0)
|
||||
seen := make(map[string]bool, len(in.Names))
|
||||
for _, raw := range in.Names {
|
||||
name := strings.TrimSpace(raw)
|
||||
// 同一个名字重复出现只处理一次:第二条回报「文件不存在」会误导操作员。
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if _, ok := uploadContentType(name); !ok {
|
||||
failed = append(failed, uploadDeleteFailure{name, "文件名非法,已跳过"})
|
||||
continue
|
||||
}
|
||||
if refs := referenced[name]; len(refs) > 0 {
|
||||
failed = append(failed, uploadDeleteFailure{name, "仍被引用(" + strings.Join(refs, "、") + "),已跳过"})
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
failed = append(failed, uploadDeleteFailure{name, "文件不存在,已跳过"})
|
||||
continue
|
||||
}
|
||||
failed = append(failed, uploadDeleteFailure{name, "删除失败:" + err.Error()})
|
||||
continue
|
||||
}
|
||||
deleted = append(deleted, name)
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"files": deleted}, nil)
|
||||
}
|
||||
return deleteUploadsResult{Deleted: deleted, Failed: failed}, nil
|
||||
}
|
||||
Reference in new issue
Block a user