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
@@ -0,0 +1,86 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// action 过滤是子串模糊匹配,且与 entityType / userId 可同时组合(AND)。
|
||||
func TestAuditLogsActionFilter(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
adminID := env.createUser("admin", "admin-pass", "admin")
|
||||
recorderID := env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
// 登录本身会写一条审计,先清干净再做确定性断言。
|
||||
env.exec(t, `DELETE FROM audit_logs`)
|
||||
seed := func(action, entity string, user int64) {
|
||||
env.exec(t, `INSERT INTO audit_logs(action, entity_type, entity_id, user_id, ip, request_id)
|
||||
VALUES (?, ?, 1, ?, '127.0.0.1', 'req')`, action, entity, user)
|
||||
}
|
||||
seed("team.create", "team", adminID)
|
||||
seed("team.remove", "team", recorderID)
|
||||
seed("result.publish", "event", adminID)
|
||||
seed("auth.login", "user", recorderID)
|
||||
seed("a_b", "team", adminID) // 用于验证 LIKE 的 _ 被转义成字面量
|
||||
seed("axb", "team", adminID)
|
||||
|
||||
list := func(query string) []map[string]any {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/audit-logs"+query, nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("查询 %s 应 200,得到 %d %+v", query, status, env2.Error)
|
||||
}
|
||||
raw, _ := decodeData[map[string]any](t, env2)["items"].([]any)
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
items = append(items, item.(map[string]any))
|
||||
}
|
||||
return items
|
||||
}
|
||||
count := func(query string) int {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/audit-logs"+query, nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("查询 %s 应 200,得到 %d %+v", query, status, env2.Error)
|
||||
}
|
||||
return int(decodeData[map[string]any](t, env2)["total"].(float64))
|
||||
}
|
||||
|
||||
// 模糊匹配:team. 命中两条,publish 命中 result.publish。
|
||||
if got := count("?action=team."); got != 2 {
|
||||
t.Errorf("action=team. 应命中 2 条,得到 %d", got)
|
||||
}
|
||||
if got := count("?action=publish"); got != 1 {
|
||||
t.Errorf("action=publish 应命中 1 条,得到 %d", got)
|
||||
}
|
||||
// _ 按字面量匹配,不当作通配符。
|
||||
if got := count("?action=a_b"); got != 1 {
|
||||
t.Errorf("action=a_b 应只命中字面量 1 条,得到 %d", got)
|
||||
}
|
||||
// 组合过滤是 AND。
|
||||
for _, q := range []struct {
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"?action=team.&entityType=team", 2},
|
||||
{fmt.Sprintf("?action=team.&userId=%d", recorderID), 1},
|
||||
{fmt.Sprintf("?action=a_b&entityType=team&userId=%d", adminID), 1},
|
||||
{fmt.Sprintf("?action=team.&entityType=team&userId=%d", recorderID), 1},
|
||||
{"?action=ghost", 0},
|
||||
} {
|
||||
if got := count(q.query); got != q.want {
|
||||
t.Errorf("查询 %s 应命中 %d 条,得到 %d", q.query, q.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回项必须真的满足过滤条件。
|
||||
for _, item := range list(fmt.Sprintf("?action=team.&entityType=team&userId=%d", recorderID)) {
|
||||
if !strings.Contains(item["action"].(string), "team.") || item["entityType"] != "team" || item["userId"] != float64(recorderID) {
|
||||
t.Fatalf("组合过滤返回了不匹配的记录: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
)
|
||||
|
||||
// safetyCopiesIn 列出备份目录里的安全副本文件名(排序后,便于逐字比对)。
|
||||
func safetyCopiesIn(t *testing.T, dir, prefix string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读目录 %s: %v", dir, err)
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// 安全副本自己那条保留策略:每类库最近 5 份,超出的按 mtime 从旧到新删,最新那份永不删。
|
||||
// 它必须和普通备份的 20 份滚动互不干扰(那份只认 <库>- 前缀)。
|
||||
func TestRestorePrunesOldSafetyCopies(t *testing.T) {
|
||||
env, _, dataDir := backupRestoreEnv(t)
|
||||
env.seed(t)
|
||||
client := env.loginAdmin(t)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("手动备份 status=%d", status)
|
||||
}
|
||||
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
|
||||
raw, err := os.ReadFile(eventBackup.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取备份: %v", err)
|
||||
}
|
||||
|
||||
// 预置 6 份旧安全副本(内容是真的库文件,只是 mtime 依次往前拨),
|
||||
// 再加本次恢复自己生成的那一份,一共 7 份 → 应裁到 5 份、删掉最旧的两份。
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
base := time.Now().Add(-6 * time.Hour)
|
||||
seeded := make([]string, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
name := fmt.Sprintf("pre-restore-event-20260911-0000%02d.db", i)
|
||||
path := filepath.Join(backupsDir, name)
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatalf("预置安全副本: %v", err)
|
||||
}
|
||||
at := base.Add(time.Duration(i) * time.Minute)
|
||||
if err := os.Chtimes(path, at, at); err != nil {
|
||||
t.Fatalf("改 mtime: %v", err)
|
||||
}
|
||||
seeded = append(seeded, name)
|
||||
}
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": eventBackup.Name, "kind": "event"}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("恢复 status=%d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[struct {
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
|
||||
Message string `json:"message"`
|
||||
}](t, resp)
|
||||
if result.PrunedSafetyCopies != 2 {
|
||||
t.Errorf("清理份数 = %d,期望 2(7 份裁到 5 份)", result.PrunedSafetyCopies)
|
||||
}
|
||||
if !strings.Contains(result.Message, "已清理 2 份") {
|
||||
t.Errorf("回执应说明清理了几份:%q", result.Message)
|
||||
}
|
||||
|
||||
left := safetyCopiesIn(t, backupsDir, "pre-restore-event-")
|
||||
if len(left) != 5 {
|
||||
t.Fatalf("剩余安全副本 = %d 份(%v),期望 5 份", len(left), left)
|
||||
}
|
||||
// 最旧的两份(mtime 最靠前)被删;本次刚生成的那份一定还在。
|
||||
for _, name := range seeded[:2] {
|
||||
if _, err := os.Stat(filepath.Join(backupsDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("最旧的 %s 应被清理,err=%v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupsDir, result.SafetyCopy)); err != nil {
|
||||
t.Errorf("本次生成的安全副本必须保留: %v", err)
|
||||
}
|
||||
// 普通备份不属于安全副本,一份都不该被动。
|
||||
if _, err := os.Stat(eventBackup.Path); err != nil {
|
||||
t.Errorf("普通备份不该被安全副本的清理删掉: %v", err)
|
||||
}
|
||||
// 列表里普通备份仍是 2 份(system + event);安全副本另有 5 份,各算各的。
|
||||
plain := 0
|
||||
for _, item := range env.listBackups(t, client) {
|
||||
if item.Kind == "system" || item.Kind == "event" {
|
||||
plain++
|
||||
}
|
||||
}
|
||||
if plain != 2 {
|
||||
t.Errorf("普通备份份数 = %d,期望 2(system + event)", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// 安全副本也能拿来恢复:列表给出 kind=safety + dbKind=event,
|
||||
// 恢复请求照 dbKind 传 kind,服务端按文件名判定覆盖哪份库——这就是「选错时间点再退回来」。
|
||||
//
|
||||
// 安全副本这里直接用 export.SafetySnapshot 造(与 handler 内部同一条代码路径):
|
||||
// 走一次 endpoint 恢复会把测试环境里那个连接关掉、又没人重启进程,
|
||||
// 第二次请求就没法真正跑到恢复逻辑;本用例要验的是「拿安全副本恢复」这一段。
|
||||
func TestRestoreFromSafetyCopy(t *testing.T) {
|
||||
env, eventPath, dataDir := backupRestoreEnv(t)
|
||||
env.seed(t)
|
||||
client := env.loginAdmin(t)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("手动备份 status=%d", status)
|
||||
}
|
||||
// 先确认有普通备份(列表自描述的另一半:普通备份带 kind=dbKind=event)。
|
||||
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
|
||||
if eventBackup.Kind != "event" || eventBackup.DBKind != "event" {
|
||||
t.Fatalf("普通赛事库备份 kind/dbKind = (%q, %q),期望 (event, event)", eventBackup.Kind, eventBackup.DBKind)
|
||||
}
|
||||
|
||||
// 改乱:加一个单位(3 个),再给当前现场留一份安全副本。
|
||||
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
|
||||
safety, err := export.SafetySnapshot(env.ev, filepath.Join(dataDir, "backups"), export.RestoreKindEvent, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("拍安全副本: %v", err)
|
||||
}
|
||||
if safety.Kind != "safety" || safety.DBKind != "event" {
|
||||
t.Fatalf("安全副本 kind/dbKind = (%q, %q),期望 (safety, event)", safety.Kind, safety.DBKind)
|
||||
}
|
||||
|
||||
// 列表接口要能自描述:安全副本 kind=safety,但它对应的库是 event。
|
||||
listed := findBackup(t, env.listBackups(t, client), "pre-restore-event-")
|
||||
if listed.Name != safety.Name {
|
||||
t.Fatalf("列表里的安全副本 = %q,期望刚生成的那份 %q", listed.Name, safety.Name)
|
||||
}
|
||||
if listed.Kind != "safety" || listed.DBKind != "event" {
|
||||
t.Fatalf("安全副本的 kind/dbKind = (%q, %q),期望 (safety, event)", listed.Kind, listed.DBKind)
|
||||
}
|
||||
|
||||
// 用安全副本退回「改乱后」的现场:kind 传列表给的 dbKind。
|
||||
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": listed.Name, "kind": listed.DBKind}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("用安全副本恢复 status=%d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[struct {
|
||||
Kind string `json:"kind"`
|
||||
DBKind string `json:"dbKind"`
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
File string `json:"file"`
|
||||
}](t, resp)
|
||||
if result.Kind != "safety" || result.DBKind != "event" || result.File != safety.Name {
|
||||
t.Errorf("回执 = %+v,期望 kind=safety dbKind=event file=%s", result, safety.Name)
|
||||
}
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != 3 {
|
||||
t.Errorf("用安全副本退回后 teams = %d,期望 3(恢复前的现场)", got)
|
||||
}
|
||||
// 这份安全副本用完之后仍在原处(恢复是复制,不是搬走),还能再恢复一次。
|
||||
if _, err := os.Stat(safety.Path); err != nil {
|
||||
t.Errorf("安全副本文件应保留: %v", err)
|
||||
}
|
||||
// 这次恢复自己也留了安全副本,内容是恢复前的现场(3 个单位)。
|
||||
if got := countRowsAt(t, filepath.Join(dataDir, "backups", result.SafetyCopy), "teams"); got != 3 {
|
||||
t.Errorf("本次恢复留下的安全副本 teams = %d,期望 3(恢复前现场)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
)
|
||||
|
||||
// backupRestoreEnv 建一个「赛事项目目录已配置」的环境:
|
||||
// 恢复赛事库要覆盖服务端当前打开的那份 event.db,路径取自 Options.ProjectDir。
|
||||
func backupRestoreEnv(t *testing.T) (env *testEnv, eventPath, dataDir string) {
|
||||
t.Helper()
|
||||
env = newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
|
||||
eventPath = filepath.Join(o.ProjectDir, "event.db")
|
||||
dataDir = o.DataDir
|
||||
})
|
||||
return env, eventPath, dataDir
|
||||
}
|
||||
|
||||
// loginAdmin 建一个管理员并登录,返回带会话 Cookie 的客户端。
|
||||
func (e *testEnv) loginAdmin(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
e.createUser("admin", "pass-1234", "admin")
|
||||
client, _ := e.client()
|
||||
e.login(client, "admin", "pass-1234")
|
||||
return client
|
||||
}
|
||||
|
||||
type backupListEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
DBKind string `json:"dbKind"`
|
||||
}
|
||||
|
||||
// listBackups 走管理端读备份列表(恢复请求里的文件名就来自这里)。
|
||||
func (e *testEnv) listBackups(t *testing.T, client *http.Client) []backupListEntry {
|
||||
t.Helper()
|
||||
status, env := e.do(client, http.MethodGet, "/api/export/backups", nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读备份列表 status=%d %+v", status, env.Error)
|
||||
}
|
||||
return decodeData[[]backupListEntry](t, env)
|
||||
}
|
||||
|
||||
func findBackup(t *testing.T, items []backupListEntry, prefix string) backupListEntry {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if strings.HasPrefix(item.Name, prefix) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
t.Fatalf("备份列表里没有 %s 开头的文件:%+v", prefix, items)
|
||||
return backupListEntry{}
|
||||
}
|
||||
|
||||
// countRowsAt 另开只读连接数表行数:恢复会把服务端手里的连接关掉、文件换掉,
|
||||
// 断言必须来自「当前磁盘上的那份库」,而不是测试环境里那个已经不作数的句柄。
|
||||
func countRowsAt(t *testing.T, path, table string) int {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 %s.%s: %v", path, table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func lastAuditAt(t *testing.T, path string) (action, after, ip string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if err := db.QueryRow(
|
||||
`SELECT action, COALESCE(after_json, ''), COALESCE(ip, '') FROM audit_logs ORDER BY id DESC LIMIT 1`).
|
||||
Scan(&action, &after, &ip); err != nil {
|
||||
t.Fatalf("读审计: %v", err)
|
||||
}
|
||||
return action, after, ip
|
||||
}
|
||||
|
||||
func listDir(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读目录 %s: %v", dir, err)
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findInDir(t *testing.T, dir, prefix string) string {
|
||||
t.Helper()
|
||||
for _, name := range listDir(t, dir) {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s 下没有 %s 开头的文件:%v", dir, prefix, listDir(t, dir))
|
||||
return ""
|
||||
}
|
||||
|
||||
// 坏文件必须被挡在门外,并且当前库毫发无伤(这是恢复功能存在的全部前提)。
|
||||
func TestRestoreBackupRejectsBadFileWithoutTouchingCurrentDB(t *testing.T) {
|
||||
env, eventPath, dataDir := backupRestoreEnv(t)
|
||||
env.seed(t) // 2 个单位
|
||||
client := env.loginAdmin(t)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("手动备份 status=%d", status)
|
||||
}
|
||||
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
|
||||
// 备份之后再加一个单位:坏文件被拒时这个单位必须还在。
|
||||
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
|
||||
before := countRowsAt(t, eventPath, "teams")
|
||||
if before != 3 {
|
||||
t.Fatalf("改动后 teams = %d,期望 3", before)
|
||||
}
|
||||
|
||||
// 把一份「文件名像备份」的文件截断,再请求恢复它。
|
||||
truncated := filepath.Join(backupsDir, "event-manual-20260911-235959.db")
|
||||
raw, err := os.ReadFile(eventBackup.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取备份: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(truncated, raw[:len(raw)/3], 0o644); err != nil {
|
||||
t.Fatalf("写截断文件: %v", err)
|
||||
}
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": filepath.Base(truncated), "kind": "event"}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("坏文件恢复 status=%d,期望 400;resp=%+v", status, resp)
|
||||
}
|
||||
if resp.Error == nil || !strings.Contains(resp.Error.Message, "备份文件不可用") {
|
||||
t.Fatalf("坏文件提示 = %+v,期望中文「备份文件不可用」", resp.Error)
|
||||
}
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != before {
|
||||
t.Errorf("坏文件被拒后 teams = %d,期望原样 %d", got, before)
|
||||
}
|
||||
for _, name := range listDir(t, backupsDir) {
|
||||
if strings.HasPrefix(name, "pre-restore-") {
|
||||
t.Errorf("坏文件在替换之前就该被拒,不该产生安全副本:%s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件名与 kind 对不上(赛事库备份当成系统库恢复)同样必须拒绝。
|
||||
status, resp = env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": eventBackup.Name, "kind": "system"}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("kind 不匹配 status=%d,期望 400;resp=%+v", status, resp)
|
||||
}
|
||||
if resp.Error == nil || !strings.Contains(resp.Error.Message, "赛事库的备份") {
|
||||
t.Fatalf("kind 不匹配提示 = %+v,期望说明它是赛事库的备份", resp.Error)
|
||||
}
|
||||
|
||||
// 目录穿越、无关文件、认不出前缀的名字都必须被拒。
|
||||
for _, bad := range []string{"../../../system.db", "system-not-a-backup.txt", "db-manual-20260911-235959.db", ""} {
|
||||
status, _ = env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": bad, "kind": "system"}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("非法文件名 %q status=%d,期望 400", bad, status)
|
||||
}
|
||||
}
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != before {
|
||||
t.Errorf("一连串被拒的请求之后 teams = %d,期望 %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复成功路径:库回到备份时刻、留下恢复前安全副本、审计有记录、赛事库与系统库互不牵连。
|
||||
func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
|
||||
env, eventPath, dataDir := backupRestoreEnv(t)
|
||||
env.seed(t) // 2 个单位
|
||||
client := env.loginAdmin(t)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("手动备份 status=%d", status)
|
||||
}
|
||||
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
|
||||
|
||||
// 备份之后继续操作:加一个单位(模拟「改乱了」)。
|
||||
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != 3 {
|
||||
t.Fatalf("改动后 teams = %d,期望 3", got)
|
||||
}
|
||||
sysUsersBefore := countRowsAt(t, filepath.Join(dataDir, "system.db"), "users")
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": eventBackup.Name, "kind": "event"}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("恢复 status=%d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[struct {
|
||||
Kind string `json:"kind"`
|
||||
DBKind string `json:"dbKind"`
|
||||
File string `json:"file"`
|
||||
Target string `json:"target"`
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
SafetyCopyPath string `json:"safetyCopyPath"`
|
||||
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
|
||||
Restarting bool `json:"restarting"`
|
||||
Message string `json:"message"`
|
||||
}](t, resp)
|
||||
|
||||
if result.Kind != "event" || result.DBKind != "event" || result.File != eventBackup.Name || result.Target != eventPath {
|
||||
t.Errorf("恢复回执 = %+v,期望 kind=event dbKind=event file=%s target=%s", result, eventBackup.Name, eventPath)
|
||||
}
|
||||
// 备份文件名里不含项目名,回执必须提醒操作员确认来源。
|
||||
if !strings.Contains(result.Message, "不含来源项目") {
|
||||
t.Errorf("回执应提醒「备份文件名里不含来源项目」:%q", result.Message)
|
||||
}
|
||||
// 测试环境没接优雅关闭:文件已经换好,只是没有进程可重启。
|
||||
if result.Restarting {
|
||||
t.Errorf("未接入优雅关闭时 restarting 应为 false")
|
||||
}
|
||||
if !strings.HasPrefix(result.SafetyCopy, "pre-restore-event-") {
|
||||
t.Errorf("安全副本名 = %q,期望 pre-restore-event- 开头", result.SafetyCopy)
|
||||
}
|
||||
if _, err := os.Stat(result.SafetyCopyPath); err != nil {
|
||||
t.Fatalf("安全副本文件应当存在: %v", err)
|
||||
}
|
||||
if result.Message == "" || !strings.Contains(result.Message, result.SafetyCopy) {
|
||||
t.Errorf("回执提示应带上安全副本名:%q", result.Message)
|
||||
}
|
||||
|
||||
// ① 当前库回到备份时刻:备份之后加的单位没了,原有两个还在。
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
|
||||
t.Errorf("恢复后 teams = %d,期望 2(备份时刻)", got)
|
||||
}
|
||||
// ② 恢复前安全副本保存的是「改过之后」的现场(3 个单位),随时能退回来。
|
||||
if got := countRowsAt(t, result.SafetyCopyPath, "teams"); got != 3 {
|
||||
t.Errorf("安全副本 teams = %d,期望 3(恢复前现场)", got)
|
||||
}
|
||||
// ③ 审计有记录,且带上这次恢复的关键事实。
|
||||
action, after, ip := lastAuditAt(t, eventPath)
|
||||
if action != "system.restore" {
|
||||
t.Errorf("审计 action = %q,期望 system.restore", action)
|
||||
}
|
||||
for _, want := range []string{eventBackup.Name, result.SafetyCopy, "event"} {
|
||||
if !strings.Contains(after, want) {
|
||||
t.Errorf("审计 after = %q,缺少 %q", after, want)
|
||||
}
|
||||
}
|
||||
if ip == "" {
|
||||
t.Errorf("审计应记下来源 IP")
|
||||
}
|
||||
// ④ 恢复赛事库不碰系统库。
|
||||
if got := countRowsAt(t, filepath.Join(dataDir, "system.db"), "users"); got != sysUsersBefore {
|
||||
t.Errorf("恢复赛事库后系统库 users = %d,期望 %d", got, sysUsersBefore)
|
||||
}
|
||||
// ⑤ 备份文件本身还在(恢复是复制,不是搬走)。
|
||||
if _, err := os.Stat(eventBackup.Path); err != nil {
|
||||
t.Errorf("恢复后原备份应当保留: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(eventPath + "-wal"); !os.IsNotExist(err) {
|
||||
t.Errorf("恢复后不应残留 -wal,err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复系统库:只换 system.db,赛事库一行不动;审计照旧写进赛事库。
|
||||
func TestRestoreSystemBackupLeavesEventDBAlone(t *testing.T) {
|
||||
env, eventPath, dataDir := backupRestoreEnv(t)
|
||||
env.seed(t)
|
||||
client := env.loginAdmin(t)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("手动备份 status=%d", status)
|
||||
}
|
||||
sysBackup := findBackup(t, env.listBackups(t, client), "system-")
|
||||
|
||||
// 备份之后再建一个用户:恢复后它应当消失。
|
||||
env.createUser("late-user", "pass-9999", "recorder")
|
||||
sysPath := filepath.Join(dataDir, "system.db")
|
||||
before := countRowsAt(t, sysPath, "users")
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
|
||||
map[string]any{"file": sysBackup.Name, "kind": "system"}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("恢复系统库 status=%d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[struct {
|
||||
Kind string `json:"kind"`
|
||||
DBKind string `json:"dbKind"`
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
SafetyCopyPath string `json:"safetyCopyPath"`
|
||||
}](t, resp)
|
||||
if result.Kind != "system" || result.DBKind != "system" || !strings.HasPrefix(result.SafetyCopy, "pre-restore-system-") {
|
||||
t.Errorf("回执 = %+v,期望 kind=system dbKind=system 且安全副本是 pre-restore-system-", result)
|
||||
}
|
||||
|
||||
if got := countRowsAt(t, sysPath, "users"); got != before-1 {
|
||||
t.Errorf("恢复后 users = %d,期望 %d(备份时刻,晚建的用户消失)", got, before-1)
|
||||
}
|
||||
// 安全副本保留的是恢复前的系统库(含那个晚建的用户)。
|
||||
if got := countRowsAt(t, result.SafetyCopyPath, "users"); got != before {
|
||||
t.Errorf("安全副本 users = %d,期望 %d", got, before)
|
||||
}
|
||||
// 赛事库没被牵连。
|
||||
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
|
||||
t.Errorf("恢复系统库后赛事库 teams = %d,期望 2", got)
|
||||
}
|
||||
if action, _, _ := lastAuditAt(t, eventPath); action != "system.restore" {
|
||||
t.Errorf("审计 action = %q,期望 system.restore", action)
|
||||
}
|
||||
}
|
||||
@@ -99,21 +99,6 @@ func normalizeRecordBonusMode(v string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeEntryStatus 校验 JSON 接口传入的 entries.status(0005 的 CHECK)。
|
||||
//
|
||||
// 只走报名表 CSV/XLSX 导入那条链路时用不到本函数:导入有 normalizeImportStatus,
|
||||
// 它认中文别名并给出行级提示;本函数只服务于 JSON 入参,不认别名。
|
||||
func normalizeEntryStatus(v string) (string, error) {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "":
|
||||
return "registered", nil
|
||||
case "registered", "scratched", "dq", "dns":
|
||||
return strings.TrimSpace(v), nil
|
||||
default:
|
||||
return "", badRequest("status 只能是 registered / scratched / dq / dns", v)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeResultStatus 校验 results.status(0005 的 CHECK)。
|
||||
func normalizeResultStatus(v string) (string, error) {
|
||||
switch strings.TrimSpace(v) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
// createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。
|
||||
func newEntryTestServer(t *testing.T) (*server, *sql.DB) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite",
|
||||
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatalf("打开赛事库: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("迁移赛事库: %v", err)
|
||||
}
|
||||
return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db
|
||||
}
|
||||
|
||||
type entryFixture struct {
|
||||
eventA, eventB int64
|
||||
catA, catB int64
|
||||
team int64
|
||||
}
|
||||
|
||||
func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture {
|
||||
t.Helper()
|
||||
exec := func(query string, args ...any) int64 {
|
||||
res, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("取 id 失败: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
|
||||
var f entryFixture
|
||||
f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport)
|
||||
f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport)
|
||||
f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA)
|
||||
f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB)
|
||||
f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`)
|
||||
return f
|
||||
}
|
||||
|
||||
func requireAPIStatus(t *testing.T, err error, status int) *apiError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("期望错误,得到 nil")
|
||||
}
|
||||
var api *apiError
|
||||
if !errors.As(err, &api) {
|
||||
t.Fatalf("期望 *apiError,得到 %T: %v", err, err)
|
||||
}
|
||||
if api.status != status {
|
||||
t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message)
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func TestNormalizeEntryStatus(t *testing.T) {
|
||||
for _, tc := range []struct{ raw, want string }{
|
||||
{"", "registered"},
|
||||
{"dns", "dns"},
|
||||
{" scratched ", "scratched"},
|
||||
} {
|
||||
got, err := normalizeEntryStatus(tc.raw)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := normalizeEntryStatus("bogus")
|
||||
if got != "" || err == nil {
|
||||
t.Fatalf("非法 status 应报错,得到 %q, %v", got, err)
|
||||
}
|
||||
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
||||
if api.code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code)
|
||||
}
|
||||
if !strings.Contains(api.message, "status") {
|
||||
t.Fatalf("错误文案应带字段名,得到 %q", api.message)
|
||||
}
|
||||
if api.detail != "bogus" {
|
||||
t.Fatalf("detail 应是原始值,得到 %v", api.detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEntryRejectsForeignCategory(t *testing.T) {
|
||||
s, db := newEntryTestServer(t)
|
||||
f := seedEntryFixture(t, db)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"})
|
||||
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
||||
if api.message != "categoryId 不属于该小项" {
|
||||
t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message)
|
||||
}
|
||||
if api.detail != f.catB {
|
||||
t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
|
||||
t.Fatalf("统计报名项: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n)
|
||||
}
|
||||
|
||||
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"})
|
||||
if err != nil {
|
||||
t.Fatalf("同小项组别应创建成功: %v", err)
|
||||
}
|
||||
if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" {
|
||||
t.Fatalf("报名项字段不对: %+v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEntryRejectsUnknownStatus(t *testing.T) {
|
||||
s, db := newEntryTestServer(t)
|
||||
f := seedEntryFixture(t, db)
|
||||
|
||||
_, err := s.createEntry(context.Background(),
|
||||
entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"})
|
||||
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
||||
if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") {
|
||||
t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) {
|
||||
s, db := newEntryTestServer(t)
|
||||
f := seedEntryFixture(t, db)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"})
|
||||
if err != nil {
|
||||
t.Fatalf("准备报名项失败: %v", err)
|
||||
}
|
||||
|
||||
// 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。
|
||||
_, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"})
|
||||
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
||||
if api.message != "categoryId 不属于该小项" || api.detail != f.catB {
|
||||
t.Fatalf("非法组别应被拦下,得到 %+v", api)
|
||||
}
|
||||
|
||||
updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"})
|
||||
if err != nil {
|
||||
t.Fatalf("同小项组别应更新成功: %v", err)
|
||||
}
|
||||
if updated.CategoryID != f.catA || updated.Status != "scratched" {
|
||||
t.Fatalf("更新结果不对: %+v", updated)
|
||||
}
|
||||
|
||||
// 报名项不存在仍是 404,不能被组别校验降级成 400。
|
||||
_, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"})
|
||||
api = requireAPIStatus(t, err, http.StatusNotFound)
|
||||
if api.message != "报名项不存在" {
|
||||
t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api)
|
||||
}
|
||||
}
|
||||
@@ -525,3 +525,611 @@ func sameStrings(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- 排序列:留空 = 不动,显式填 0 = 写 0 ----------
|
||||
|
||||
// baseDataSheetNames 是导出 / 导入的四张工作表,顺序与表头识别无关。
|
||||
var baseDataSheetNames = []string{"单位", "大项", "小项", "组别"}
|
||||
|
||||
// findHeaderColumn 找出工作表里表头等于 header 的列:返回表头行下标(0 起)与列下标。
|
||||
// 表头行与列号都扫出来,不写死版式:导出加点什么也不会让测试指错地方。
|
||||
func findHeaderColumn(t *testing.T, f *excelize.File, sheet, header string) (int, int) {
|
||||
t.Helper()
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表 %s: %v", sheet, err)
|
||||
}
|
||||
for r, row := range rows {
|
||||
for c, v := range row {
|
||||
if strings.TrimSpace(v) == header {
|
||||
return r, c
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("工作表 %s 里找不到「%s」表头", sheet, header)
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// columnCells 找出工作表里表头等于 header 的那一列上、每个数据行的单元格坐标。
|
||||
func columnCells(t *testing.T, f *excelize.File, sheet, header string) []string {
|
||||
t.Helper()
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表 %s: %v", sheet, err)
|
||||
}
|
||||
headerRow, col := findHeaderColumn(t, f, sheet, header)
|
||||
var cells []string
|
||||
for r := headerRow + 1; r < len(rows); r++ {
|
||||
blank := true
|
||||
for _, v := range rows[r] {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
blank = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if blank {
|
||||
continue
|
||||
}
|
||||
cell, err := excelize.CoordinatesToCellName(col+1, r+1)
|
||||
if err != nil {
|
||||
t.Fatalf("算单元格坐标: %v", err)
|
||||
}
|
||||
cells = append(cells, cell)
|
||||
}
|
||||
if len(cells) == 0 {
|
||||
t.Fatalf("工作表 %s 的「%s」列没有任何数据行", sheet, header)
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// rewriteColumn 把某列的数据格全部改成 value("" 表示列在、格子空)。
|
||||
func rewriteColumn(t *testing.T, f *excelize.File, sheet, header, value string) {
|
||||
t.Helper()
|
||||
for _, cell := range columnCells(t, f, sheet, header) {
|
||||
if err := f.SetCellValue(sheet, cell, value); err != nil {
|
||||
t.Fatalf("改 %s!%s: %v", sheet, cell, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropColumn 整列删掉(表头与数据一起没),模拟现场把这一列删了:
|
||||
// 导入侧看到的就是「列缺席」——表头里找不到这一列,Has[field] 为 false。
|
||||
func dropColumn(t *testing.T, f *excelize.File, sheet, header string) {
|
||||
t.Helper()
|
||||
_, col := findHeaderColumn(t, f, sheet, header)
|
||||
name, err := excelize.ColumnNumberToName(col + 1)
|
||||
if err != nil {
|
||||
t.Fatalf("算列名: %v", err)
|
||||
}
|
||||
if err := f.RemoveCol(sheet, name); err != nil {
|
||||
t.Fatalf("删 %s 的「%s」列: %v", sheet, header, err)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteSortColumn 把某个 sheet 的排序单元格全部改成 value("" 表示留空)。
|
||||
func rewriteSortColumn(t *testing.T, f *excelize.File, sheet, value string) {
|
||||
t.Helper()
|
||||
rewriteColumn(t, f, sheet, "排序", value)
|
||||
}
|
||||
|
||||
func writeWorkbook(t *testing.T, f *excelize.File) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写回工作簿: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// seedDistinctSortOrders 把四张表的排序改成互不相同的非零值,并补一个大项:
|
||||
// 只有非零原值才能把「留空 = 保持原值」与「留空 = 写成 0」区分开。
|
||||
func seedDistinctSortOrders(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE teams SET sort_order = 1 WHERE id = ?`, ids.teamA)
|
||||
env.exec(t, `UPDATE teams SET sort_order = 6 WHERE id = ?`, ids.teamB)
|
||||
env.exec(t, `UPDATE sports SET sort_order = 1 WHERE id = ?`, ids.sport)
|
||||
env.exec(t, `INSERT INTO sports (code, name, short_name, sort_order, active) VALUES ('SWIM', '游泳', '游', 6, 1)`)
|
||||
env.exec(t, `UPDATE events SET sort_order = 1 WHERE id = ?`, ids.event)
|
||||
env.exec(t, `UPDATE event_categories SET sort_order = 1 WHERE id = ?`, ids.category)
|
||||
}
|
||||
|
||||
// baseDataOrigSorts 是种子数据在库里的排序原值,按 sheet 列出(行序同 baseDataSortRows)。
|
||||
func baseDataOrigSorts() map[string][]int {
|
||||
return map[string][]int{"单位": {1, 6}, "大项": {1, 6}, "小项": {1}, "组别": {1}}
|
||||
}
|
||||
|
||||
// baseDataSortRows 是排序断言要查的行:sheet / 名字用于报错与分组,SQL 取库里现值。
|
||||
var baseDataSortRows = []struct {
|
||||
sheet string
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"单位", "单位 A", `SELECT sort_order FROM teams WHERE code = 'A'`},
|
||||
{"单位", "单位 B", `SELECT sort_order FROM teams WHERE code = 'B'`},
|
||||
{"大项", "大项 TRACK", `SELECT sort_order FROM sports WHERE code = 'TRACK'`},
|
||||
{"大项", "大项 SWIM", `SELECT sort_order FROM sports WHERE code = 'SWIM'`},
|
||||
{"小项", "小项 M100", `SELECT sort_order FROM events WHERE code = 'M100'`},
|
||||
{"组别", "组别 男子", `SELECT sort_order FROM event_categories WHERE name = '男子'`},
|
||||
}
|
||||
|
||||
// assertSortOrders 断言四张表的排序:want 按 sheet 给出该表每一行的期望值。
|
||||
func assertSortOrders(t *testing.T, env *testEnv, want map[string][]int) {
|
||||
t.Helper()
|
||||
next := map[string]int{}
|
||||
for _, row := range baseDataSortRows {
|
||||
var got int
|
||||
if err := env.ev.QueryRow(row.query).Scan(&got); err != nil {
|
||||
t.Fatalf("查询%s排序: %v", row.name, err)
|
||||
}
|
||||
values, ok := want[row.sheet]
|
||||
if !ok || next[row.sheet] >= len(values) {
|
||||
t.Fatalf("断言表缺 %s 的期望值", row.sheet)
|
||||
}
|
||||
if expected := values[next[row.sheet]]; got != expected {
|
||||
t.Errorf("%s 排序 = %d,期望 %d", row.name, got, expected)
|
||||
}
|
||||
next[row.sheet]++
|
||||
}
|
||||
}
|
||||
|
||||
// zeroedSorts 取一份「本 sheet 全为 0、其余保持原值」的期望表。
|
||||
func zeroedSorts(sheet string) map[string][]int {
|
||||
want := baseDataOrigSorts()
|
||||
want[sheet] = make([]int, len(want[sheet]))
|
||||
return want
|
||||
}
|
||||
|
||||
// baseDataAdminClient 建一个 admin 账号并登录,返回带会话与 CSRF Cookie 的客户端。
|
||||
func baseDataAdminClient(t *testing.T, env *testEnv) *http.Client {
|
||||
t.Helper()
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
return client
|
||||
}
|
||||
|
||||
// exportBaseDataWorkbook 导出基础数据包并打开工作簿(改完用 writeWorkbook 写回)。
|
||||
func exportBaseDataWorkbook(t *testing.T, env *testEnv, client *http.Client) *excelize.File {
|
||||
t.Helper()
|
||||
status, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body)
|
||||
}
|
||||
f := openWorkbook(t, body)
|
||||
t.Cleanup(func() { _ = f.Close() })
|
||||
return f
|
||||
}
|
||||
|
||||
// importPackage 上传基础数据包并解出结果(fields 给 dryRun=1 就是预览)。
|
||||
func importPackage(t *testing.T, env *testEnv, client *http.Client, pack []byte, fields map[string]string) baseDataResult {
|
||||
t.Helper()
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, fields)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导入应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
return decodeBaseDataResult(t, raw)
|
||||
}
|
||||
|
||||
// assertNoFieldDiff 断言差异里没有这几个字段(表格里 / 库里的中文字段名)。
|
||||
func assertNoFieldDiff(t *testing.T, res baseDataResult, fields ...string) {
|
||||
t.Helper()
|
||||
unwanted := make(map[string]bool, len(fields))
|
||||
for _, name := range fields {
|
||||
unwanted[name] = true
|
||||
}
|
||||
for _, ch := range res.Changes {
|
||||
for _, fld := range ch.Fields {
|
||||
if unwanted[fld.Field] {
|
||||
t.Errorf("%s %s 的 %s 不该有差异,得到 %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSortDiff 断言差异里没有「排序」这一列。
|
||||
func assertNoSortDiff(t *testing.T, res baseDataResult) {
|
||||
t.Helper()
|
||||
assertNoFieldDiff(t, res, "排序")
|
||||
}
|
||||
|
||||
// 排序列留空 = 保持原值:四个 sheet 各一条(曾经单位 / 大项被写成 0)。
|
||||
func TestBaseDataImportBlankSortOrderKeepsExisting(t *testing.T) {
|
||||
for _, sheet := range baseDataSheetNames {
|
||||
t.Run(sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteSortColumn(t, f, sheet, "")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
// 预览:只清空了排序列,一条差异都不该有。
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if !res.DryRun {
|
||||
t.Errorf("dryRun 应为 true")
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("排序留空不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Unchanged != 6 {
|
||||
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
|
||||
}
|
||||
assertNoSortDiff(t, res)
|
||||
|
||||
// 真写入:库里的排序仍是原值。
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("排序留空不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
assertSortOrders(t, env, baseDataOrigSorts())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:排序列显式填 0 是「写 0」,不是「留空」——证明修的是区分,不是把排序列冻住。
|
||||
func TestBaseDataImportExplicitZeroSortOrderIsWritten(t *testing.T) {
|
||||
for _, sheet := range baseDataSheetNames {
|
||||
t.Run(sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteSortColumn(t, f, sheet, "0")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
wantUpdated := len(baseDataOrigSorts()[sheet])
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Updated != wantUpdated {
|
||||
t.Fatalf("更新条数 = %d,期望 %d(%s 的排序被显式改成 0)", res.Updated, wantUpdated, sheet)
|
||||
}
|
||||
for _, ch := range res.Changes {
|
||||
if ch.Sheet != sheet {
|
||||
t.Errorf("只改了 %s,不该动 %s", sheet, ch.Sheet)
|
||||
}
|
||||
for _, fld := range ch.Fields {
|
||||
if fld.Field != "排序" || fld.To != "0" {
|
||||
t.Errorf("%s %s 出现了排序以外的差异:%s %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Updated != wantUpdated {
|
||||
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, wantUpdated)
|
||||
}
|
||||
assertSortOrders(t, env, zeroedSorts(sheet))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 布尔列:缺列 = 不动,格子空 = 取「是」,显式「否」= 停用 ----------
|
||||
|
||||
// baseDataBoolSheets 是带布尔列的 sheet 与两者的对照基线(组别表没有任何布尔列,故不在表里):
|
||||
//
|
||||
// blankUpdated:清空布尔格后应被更新的行数(原本就是「是」的行不算差异)
|
||||
// noUpdated:显式填「否」后应被更新的行数(这一组用纯种子,所有行原本都是「是」)
|
||||
var baseDataBoolSheets = []struct {
|
||||
sheet string
|
||||
headers []string
|
||||
blankUpdated int
|
||||
noUpdated int
|
||||
}{
|
||||
{"单位", []string{"启用"}, 1, 2},
|
||||
{"大项", []string{"启用"}, 1, 2},
|
||||
{"小项", []string{"计分", "计破纪录"}, 1, 1},
|
||||
}
|
||||
|
||||
// yesNo 把布尔值渲染成界面上那一列的文字。
|
||||
func yesNo(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
// boolExpect 给出该 sheet 的布尔字段「全部为 yes」时应有的库内渲染,版式与 boolState 一致。
|
||||
func boolExpect(sheet string, yes bool) string {
|
||||
switch sheet {
|
||||
case "单位":
|
||||
return "A=" + yesNo(yes) + " B=" + yesNo(yes)
|
||||
case "大项":
|
||||
return "SWIM=" + yesNo(yes) + " TRACK=" + yesNo(yes)
|
||||
case "小项":
|
||||
return "M100 计分=" + yesNo(yes) + " 计破纪录=" + yesNo(yes)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// boolState 读出该 sheet 布尔字段的库内现状,渲染成与 boolExpect 同版式的字符串。
|
||||
func boolState(t *testing.T, env *testEnv, sheet string) string {
|
||||
t.Helper()
|
||||
switch sheet {
|
||||
case "单位", "大项":
|
||||
table := "teams"
|
||||
if sheet == "大项" {
|
||||
table = "sports"
|
||||
}
|
||||
rows, err := env.ev.Query(`SELECT code, active FROM ` + table + ` ORDER BY code`)
|
||||
if err != nil {
|
||||
t.Fatalf("查询%s: %v", sheet, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var parts []string
|
||||
for rows.Next() {
|
||||
var (
|
||||
code string
|
||||
active int
|
||||
)
|
||||
if err := rows.Scan(&code, &active); err != nil {
|
||||
t.Fatalf("扫描%s: %v", sheet, err)
|
||||
}
|
||||
parts = append(parts, code+"="+yesNo(active != 0))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历%s: %v", sheet, err)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
case "小项":
|
||||
var scored, recordEligible int
|
||||
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = 'M100'`).
|
||||
Scan(&scored, &recordEligible); err != nil {
|
||||
t.Fatalf("查询小项布尔字段: %v", err)
|
||||
}
|
||||
return "M100 计分=" + yesNo(scored != 0) + " 计破纪录=" + yesNo(recordEligible != 0)
|
||||
default:
|
||||
t.Fatalf("sheet %s 没有布尔列,不该拿它查布尔状态", sheet)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// seedDisabledBooleans 造出「本来就有一半是停用」的现状:
|
||||
// 单位 B 停用、大项 SWIM 停用、小项 M100 不计分也不计破纪录。
|
||||
// 只有原本为「否」的行,才能把「缺列 = 不动」和「缺列 = 改成是」区分开。
|
||||
func seedDisabledBooleans(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE teams SET active = 0 WHERE id = ?`, ids.teamB)
|
||||
env.exec(t, `UPDATE sports SET active = 0 WHERE code = 'SWIM'`)
|
||||
env.exec(t, `UPDATE events SET scored = 0, record_eligible = 0 WHERE id = ?`, ids.event)
|
||||
}
|
||||
|
||||
// assertBoolDiff 断言差异只出现在该 sheet 的布尔列上,且都是 wantFrom→wantTo。
|
||||
func assertBoolDiff(t *testing.T, res baseDataResult, sheet string, headers []string, wantFrom, wantTo string) {
|
||||
t.Helper()
|
||||
allowed := make(map[string]bool, len(headers))
|
||||
for _, h := range headers {
|
||||
allowed[h] = true
|
||||
}
|
||||
seen := 0
|
||||
for _, ch := range res.Changes {
|
||||
if ch.Sheet != sheet {
|
||||
t.Errorf("只改了 %s,不该动 %s", sheet, ch.Sheet)
|
||||
continue
|
||||
}
|
||||
for _, fld := range ch.Fields {
|
||||
if !allowed[fld.Field] {
|
||||
t.Errorf("%s %s 出现了布尔列以外的差异:%s %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
|
||||
continue
|
||||
}
|
||||
if fld.From != wantFrom || fld.To != wantTo {
|
||||
t.Errorf("%s %s 的 %s = %s→%s,期望 %s→%s",
|
||||
ch.Sheet, ch.Key, fld.Field, fld.From, fld.To, wantFrom, wantTo)
|
||||
}
|
||||
seen++
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Errorf("%s 一条布尔差异都没有,用例没测到东西", sheet)
|
||||
}
|
||||
}
|
||||
|
||||
// 布尔列整列删掉 = 不动该字段(组别表没有布尔列,只覆盖三张表)。
|
||||
// 关键对照:库里原本停用的行,导入后仍是停用——绝不出现「少写一列 = 全部启用」。
|
||||
func TestBaseDataImportAbsentBooleanColumnKeepsExisting(t *testing.T) {
|
||||
for _, tc := range baseDataBoolSheets {
|
||||
t.Run(tc.sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
seedDisabledBooleans(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
for _, header := range tc.headers {
|
||||
dropColumn(t, f, tc.sheet, header)
|
||||
}
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
before := boolState(t, env, tc.sheet)
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("布尔列缺列不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Unchanged != 6 {
|
||||
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
|
||||
}
|
||||
assertNoFieldDiff(t, res, tc.headers...)
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("布尔列缺列不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if after := boolState(t, env, tc.sheet); after != before {
|
||||
t.Errorf("布尔字段被改动了:%s → %s(缺列应保持原值)", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 布尔列在、格子空 = 取「是」:与「缺列」区分开(缺列那半边见上一个用例)。
|
||||
func TestBaseDataImportBlankBooleanCellMeansYes(t *testing.T) {
|
||||
for _, tc := range baseDataBoolSheets {
|
||||
t.Run(tc.sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
seedDisabledBooleans(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
for _, header := range tc.headers {
|
||||
rewriteColumn(t, f, tc.sheet, header, "")
|
||||
}
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Updated != tc.blankUpdated {
|
||||
t.Fatalf("更新条数 = %d,期望 %d(%s 原本为「否」的行应变成「是」)",
|
||||
res.Updated, tc.blankUpdated, tc.sheet)
|
||||
}
|
||||
assertBoolDiff(t, res, tc.sheet, tc.headers, "否", "是")
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Updated != tc.blankUpdated {
|
||||
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, tc.blankUpdated)
|
||||
}
|
||||
if got, want := boolState(t, env, tc.sheet), boolExpect(tc.sheet, true); got != want {
|
||||
t.Errorf("布尔字段 = %s,期望 %s(格子空取「是」)", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 布尔列显式填「否」= 真的停用:证明改的是「缺列 / 空格 vs 显式值」的区分,不是把布尔列冻住。
|
||||
func TestBaseDataImportExplicitNoDisables(t *testing.T) {
|
||||
for _, tc := range baseDataBoolSheets {
|
||||
t.Run(tc.sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
for _, header := range tc.headers {
|
||||
rewriteColumn(t, f, tc.sheet, header, "否")
|
||||
}
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Updated != tc.noUpdated {
|
||||
t.Fatalf("更新条数 = %d,期望 %d(%s 全表显式改成「否」)", res.Updated, tc.noUpdated, tc.sheet)
|
||||
}
|
||||
assertBoolDiff(t, res, tc.sheet, tc.headers, "是", "否")
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Updated != tc.noUpdated {
|
||||
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, tc.noUpdated)
|
||||
}
|
||||
if got, want := boolState(t, env, tc.sheet), boolExpect(tc.sheet, false); got != want {
|
||||
t.Errorf("布尔字段 = %s,期望 %s(显式「否」应写成停用)", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 排序列整列删掉 = 不动该字段:单位 / 大项各一条(补上上一轮只测了「格子空」的另一半)。
|
||||
func TestBaseDataImportAbsentSortColumnKeepsExisting(t *testing.T) {
|
||||
for _, sheet := range []string{"单位", "大项"} {
|
||||
t.Run(sheet, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedDistinctSortOrders(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
dropColumn(t, f, sheet, "排序")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("排序列缺列不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Unchanged != 6 {
|
||||
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
|
||||
}
|
||||
assertNoSortDiff(t, res)
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("排序列缺列不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
assertSortOrders(t, env, baseDataOrigSorts())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 对照:这一轮只统一布尔列的「缺列」口径,别的列一个字没动——
|
||||
// 文本列留空仍清空、权重留空仍取 1(两条已知且被文档写准的口径)。
|
||||
func TestBaseDataImportBlankTextAndWeightKeepTheirRules(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.exec(t, `UPDATE event_categories SET multiplier = 2 WHERE id = ?`, ids.category)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, "单位", "简称", "")
|
||||
rewriteColumn(t, f, "组别", "权重", "")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.Added != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Updated != 3 {
|
||||
t.Fatalf("更新条数 = %d,期望 3(2 个单位清掉简称 + 1 个组别回到权重 1)", res.Updated)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, ch := range res.Changes {
|
||||
for _, fld := range ch.Fields {
|
||||
seen[ch.Sheet+"/"+ch.Key+"."+fld.Field] = fld.From + "→" + fld.To
|
||||
}
|
||||
}
|
||||
if got := seen["单位/A.简称"]; got != "一司→" {
|
||||
t.Errorf("单位 A 简称差异 = %q,期望 一司→(空)", got)
|
||||
}
|
||||
if got := seen["单位/B.简称"]; got != "二司→" {
|
||||
t.Errorf("单位 B 简称差异 = %q,期望 二司→(空)", got)
|
||||
}
|
||||
if got := seen["组别/M100 / 男子.权重"]; got != "2→1" {
|
||||
t.Errorf("组别权重差异 = %q,期望 2→1", got)
|
||||
}
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Updated != 3 {
|
||||
t.Fatalf("落地后更新条数 = %d,期望 3", res.Updated)
|
||||
}
|
||||
var shortName string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(short_name, '') FROM teams WHERE code = 'A'`).Scan(&shortName); err != nil {
|
||||
t.Fatalf("查询单位简称: %v", err)
|
||||
}
|
||||
if shortName != "" {
|
||||
t.Errorf("文本列留空应清空,得到 %q", shortName)
|
||||
}
|
||||
var multiplier float64
|
||||
if err := env.ev.QueryRow(`SELECT multiplier FROM event_categories WHERE id = ?`, ids.category).
|
||||
Scan(&multiplier); err != nil {
|
||||
t.Fatalf("查询组别权重: %v", err)
|
||||
}
|
||||
if multiplier != 1 {
|
||||
t.Errorf("权重留空应取 1,得到 %v", multiplier)
|
||||
}
|
||||
}
|
||||
@@ -605,7 +605,14 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "单位名称必填")
|
||||
continue
|
||||
}
|
||||
plan := baseDataTeamPlan{line: line, code: code, name: name, active: true}
|
||||
// 起手值一律取库里现值:**列缺席 = 不动该字段**(布尔列也一样,见下面的解析块),
|
||||
// 列在但格子空才落到「取默认值」:排序空值不改起手值、启用空值由解析取「是」。
|
||||
// 新建时 p.teams 取不到,给与建表默认一致的起手值:启用、排序 0。
|
||||
existing, ok := p.teams[code]
|
||||
if !ok {
|
||||
existing = baseDataExistingTeam{active: true}
|
||||
}
|
||||
plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
|
||||
if row.Has["active"] {
|
||||
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
|
||||
if !ok {
|
||||
@@ -624,7 +631,6 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
|
||||
plan.sortOrder = *n
|
||||
}
|
||||
}
|
||||
existing, ok := p.teams[code]
|
||||
if !ok {
|
||||
plan.action = "added"
|
||||
if row.Has["shortName"] {
|
||||
@@ -685,7 +691,13 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "大项名称必填")
|
||||
continue
|
||||
}
|
||||
plan := baseDataSportPlan{line: line, code: code, name: name, active: true}
|
||||
// 与单位同口径:起手取库里现值,列缺席 = 不动该字段(启用也一样);
|
||||
// 列在(row.Has 为真)才走下面的解析块,格子空由它取默认「是」。新建才落默认:启用、排序 0。
|
||||
existing, ok := p.sports[code]
|
||||
if !ok {
|
||||
existing = baseDataExistingSport{active: true}
|
||||
}
|
||||
plan := baseDataSportPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
|
||||
if row.Has["active"] {
|
||||
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
|
||||
if !ok {
|
||||
@@ -704,7 +716,6 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
|
||||
plan.sortOrder = *n
|
||||
}
|
||||
}
|
||||
existing, ok := p.sports[code]
|
||||
if !ok {
|
||||
plan.action = "added"
|
||||
if row.Has["shortName"] {
|
||||
@@ -1447,3 +1458,249 @@ func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// backupRestoreResult 是恢复请求被接受后的回执。
|
||||
//
|
||||
// SafetyCopy 是「恢复前安全副本」的文件名,必须回报给操作员:
|
||||
// 应用这次恢复的理由是数据被改乱了,那恢复本身错了(选错时间点 / 选错库)的概率就不低,
|
||||
// 现场要能立刻拿副本退回。
|
||||
type backupRestoreResult struct {
|
||||
// Kind 是这次用的文件的性质:system / event / safety(安全副本也能拿来退回)。
|
||||
Kind string `json:"kind"`
|
||||
// DBKind 是被恢复的库:system / event。
|
||||
DBKind string `json:"dbKind"`
|
||||
KindLabel string `json:"kindLabel"`
|
||||
File string `json:"file"`
|
||||
Target string `json:"target"`
|
||||
// SafetyCopy 与本策略同名文件在备份目录里的完整路径;PrunedSafetyCopies 是这次顺带清掉的旧副本份数。
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
SafetyCopyPath string `json:"safetyCopyPath"`
|
||||
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
|
||||
Restarting bool `json:"restarting"`
|
||||
Message string `json:"message"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
|
||||
//
|
||||
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
|
||||
// 不在运行中热替换——Windows 上替换不了正被打开的文件,硬写会把库写坏;
|
||||
// 所以这里先把目标库的连接关掉(服务端从此不再持有它),整文件替换,再触发重启。
|
||||
//
|
||||
// 顺序是有讲究的,任何一步失败都不能留下「半截」状态:
|
||||
//
|
||||
// 校验备份(坏文件直接 400,当前库毫发无伤)
|
||||
// → 给当前库拍安全副本(失败就放弃,什么都没动)
|
||||
// → 落恢复标记(死了也有线索)
|
||||
// → 关连接、替换文件
|
||||
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
|
||||
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in restoreBackupPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, ok := export.ParseRestoreKind(in.Kind)
|
||||
if !ok {
|
||||
return nil, badRequest("请指定要恢复的库:kind 只能是 system(系统库)或 event(赛事库)", nil)
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(in.File)
|
||||
if name == "" {
|
||||
return nil, badRequest("请选择要恢复的备份文件", nil)
|
||||
}
|
||||
// 只收裸文件名:带分隔符(或 ..)的输入会把恢复变成「任意文件覆盖任意路径」。
|
||||
if name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
return nil, badRequest("备份文件名非法,只能填备份目录里的文件名", nil)
|
||||
}
|
||||
fileKind, fileDBKind, ok := export.KindOfBackupName(name)
|
||||
if !ok {
|
||||
return nil, badRequest("无法从这个文件名判断它属于哪个库,请从备份列表里选择", nil)
|
||||
}
|
||||
// 比的是「它对应的库」:安全副本的性质是 safety,但覆盖的仍是它那一份库,
|
||||
// 拿安全副本退回上一个时间点是正当操作(列表接口会给出 dbKind)。
|
||||
if fileDBKind != kind {
|
||||
return nil, badRequest(fmt.Sprintf("%s 是%s的备份,不能恢复成%s;请确认要恢复的库",
|
||||
name, fileDBKind.Label(), kind.Label()), nil)
|
||||
}
|
||||
|
||||
backupsDir := filepath.Join(s.dataDir, "backups")
|
||||
backupPath := filepath.Join(backupsDir, name)
|
||||
if err := export.ValidateBackup(backupPath, kind); err != nil {
|
||||
return nil, badRequest(trimExportPrefix(err.Error()), nil)
|
||||
}
|
||||
|
||||
target, db, apiErr := s.restoreTarget(kind)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
// 上一次恢复(或任何原因)已经把连接关掉了:这时再往下走,拍安全副本只会撞上
|
||||
// 「sql: database is closed」,报出来是一句看不懂的 500。直接说清「先重启服务端」。
|
||||
if err := db.PingContext(r.Context()); err != nil && dbClosed(err) {
|
||||
return nil, serviceUnavailable("INTERNAL", "数据库连接已关闭(上一次恢复后尚未重启),请先重启服务端再恢复")
|
||||
}
|
||||
|
||||
safety, err := export.SafetySnapshot(db, backupsDir, kind, time.Now())
|
||||
if err != nil {
|
||||
return nil, internalMsg("生成恢复前安全副本失败,当前库未做任何改动", err)
|
||||
}
|
||||
// 安全副本自己那份保留策略(每类库最近 5 份,最新的永不删),与普通备份的 20 份滚动互不干扰。
|
||||
// 清不掉旧副本不算恢复失败:磁盘上多留一份整库文件,比因此拒绝一次现场恢复划算得多。
|
||||
pruned := 0
|
||||
if removed, pruneErr := export.PruneSafetyCopies(backupsDir, kind, export.DefaultSafetyCopyKeep); pruneErr != nil {
|
||||
s.logger.Warn("清理旧安全副本失败", "kind", string(kind), "err", pruneErr)
|
||||
} else {
|
||||
pruned = len(removed)
|
||||
if pruned > 0 {
|
||||
s.logger.Info("已清理旧安全副本", "kind", string(kind),
|
||||
"removed", pruned, "keep", export.DefaultSafetyCopyKeep)
|
||||
}
|
||||
}
|
||||
|
||||
operator := "guest"
|
||||
if u := middleware.User(r.Context()); u != nil && u.ID != 0 {
|
||||
operator = fmt.Sprintf("%s#%d", u.Username, u.ID)
|
||||
}
|
||||
markerPath, err := export.WritePendingRestore(s.dataDir, export.PendingRestore{
|
||||
Kind: kind,
|
||||
Backup: backupPath,
|
||||
BackupName: name,
|
||||
Target: target,
|
||||
SafetyCopy: safety.Path,
|
||||
Operator: operator,
|
||||
RequestedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, internalMsg("写入恢复标记失败,当前库未做任何改动", err)
|
||||
}
|
||||
|
||||
// 停机第一步:断开目标库的全部连接。此后服务端不再持有这个库,
|
||||
// 直到本次请求触发优雅退出、Console 把服务端重新拉起来。
|
||||
if err := db.Close(); err != nil {
|
||||
s.logger.Warn("关闭目标库连接失败,继续恢复", "kind", string(kind), "err", err)
|
||||
}
|
||||
if err := export.ApplyRestore(backupPath, target); err != nil {
|
||||
// 连接已经断开,即使文件没换成,也得让服务端重启一次把库重新打开。
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("替换数据库失败,当前库文件未被改动;恢复标记留在 "+markerPath+
|
||||
";请用恢复前安全副本 "+safety.Path+" 退回", err)
|
||||
}
|
||||
// 备份在替换前校验过一次,这里是兜底复核:万一新库打不开,立刻退回安全副本,
|
||||
// 绝不能让一次恢复留下一个打不开的库。
|
||||
if err := export.ValidateBackup(target, kind); err != nil {
|
||||
if rbErr := export.ApplyRestore(safety.Path, target); rbErr != nil {
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("恢复后校验失败,且自动回滚也失败;请立即用安全副本 "+
|
||||
safety.Path+" 手工恢复", err)
|
||||
}
|
||||
_ = export.ClearPendingRestore(s.dataDir)
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("恢复后校验失败,已自动回滚到恢复前状态(安全副本 "+safety.Name+")", err)
|
||||
}
|
||||
_ = export.ClearPendingRestore(s.dataDir)
|
||||
|
||||
// 审计直接写给「恢复之后还在的那份库」(审计表在赛事库);
|
||||
// 恢复赛事库时旧连接已关、旧文件已换,走中间件的审计只会留在被换掉的文件上。
|
||||
warning := ""
|
||||
if err := s.recordRestoreAudit(r, kind, name, target, safety.Name); err != nil {
|
||||
s.logger.Error("恢复审计写入失败", "kind", string(kind), "err", err)
|
||||
warning = "恢复已完成,但审计写入失败:" + err.Error()
|
||||
}
|
||||
|
||||
base := fmt.Sprintf("已用 %s 恢复%s", name, kind.Label())
|
||||
if s.shutdown == nil {
|
||||
// 裸服务端(没接 Console)没有可触发的优雅重启,库文件已经换好但进程还抱着旧连接,
|
||||
// 只能由操作员手工重启——回执必须说实话,不能让人以为它会自己回来。
|
||||
base += "。服务端未接入自动重启,请手工重启服务后刷新页面"
|
||||
} else {
|
||||
base += ";服务端正在重启,请稍候刷新页面"
|
||||
}
|
||||
notes := []string{"恢复前安全副本:" + safety.Name}
|
||||
if pruned > 0 {
|
||||
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
|
||||
pruned, export.DefaultSafetyCopyKeep))
|
||||
}
|
||||
if kind == export.RestoreKindEvent {
|
||||
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
|
||||
notes = append(notes, "赛事库备份的文件名里不含来源项目,恢复前请确认来源")
|
||||
}
|
||||
|
||||
result := backupRestoreResult{
|
||||
Kind: fileKind,
|
||||
DBKind: string(kind),
|
||||
KindLabel: kind.Label(),
|
||||
File: name,
|
||||
Target: target,
|
||||
SafetyCopy: safety.Name,
|
||||
SafetyCopyPath: safety.Path,
|
||||
PrunedSafetyCopies: pruned,
|
||||
Restarting: s.shutdown != nil,
|
||||
Message: base + "。" + strings.Join(notes, ";"),
|
||||
Warning: warning,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
|
||||
s.triggerRestart(kind)
|
||||
return nil, errHandled
|
||||
}
|
||||
|
||||
// triggerRestart 走「切换赛事」同一条收尾:优雅退出,由 Console 拉起服务端。
|
||||
//
|
||||
// 恢复一旦执行到「关掉目标库连接」这一步,无论后面成没成,都必须重启一次——
|
||||
// 连接已经断开,只有新进程会把库重新打开。调用点在响应写完之后:
|
||||
// srv.Shutdown 会等本次请求(含响应与审计落库)结束,不会截断回执。
|
||||
func (s *server) triggerRestart(kind export.RestoreKind) {
|
||||
if s.shutdown == nil {
|
||||
// 与切换赛事一致的降级:没接入优雅关闭时文件已经换好,下次重启即生效。
|
||||
s.logger.Warn("服务端未接入优雅关闭,恢复将在下次重启后生效", "kind", string(kind))
|
||||
return
|
||||
}
|
||||
go s.shutdown()
|
||||
}
|
||||
|
||||
// restoreTarget 返回要恢复的库文件路径与当前连接。
|
||||
// 赛事库的路径取自服务端实际打开的赛事目录,而不是从记忆文件重新推一遍:
|
||||
// 恢复必须覆盖「当前正在用的那份」,退役项目里的同名文件不是目标。
|
||||
func (s *server) restoreTarget(kind export.RestoreKind) (string, *sql.DB, error) {
|
||||
switch kind {
|
||||
case export.RestoreKindSystem:
|
||||
if strings.TrimSpace(s.dataDir) == "" || s.sys == nil {
|
||||
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置数据目录,无法恢复系统库")
|
||||
}
|
||||
return filepath.Join(s.dataDir, "system.db"), s.sys, nil
|
||||
case export.RestoreKindEvent:
|
||||
if strings.TrimSpace(s.projectDir) == "" || s.ev == nil {
|
||||
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置赛事项目目录,无法恢复赛事库")
|
||||
}
|
||||
return filepath.Join(s.projectDir, "event.db"), s.ev, nil
|
||||
default:
|
||||
return "", nil, badRequest("要恢复的库只能是 system 或 event", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// recordRestoreAudit 把这次恢复写进赛事库的 audit_logs(audit 表在赛事库,系统库没有)。
|
||||
func (s *server) recordRestoreAudit(r *http.Request, kind export.RestoreKind, file, target, safetyName string) error {
|
||||
if strings.TrimSpace(s.projectDir) == "" {
|
||||
return errors.New("未配置赛事项目目录,没有可写审计的库")
|
||||
}
|
||||
detail := map[string]any{
|
||||
"kind": string(kind),
|
||||
"file": file,
|
||||
"target": target,
|
||||
"safetyCopy": safetyName,
|
||||
}
|
||||
if project := currentProjectName(s.projectDir); project != "" {
|
||||
detail["project"] = project
|
||||
}
|
||||
return export.RecordRestoreAudit(filepath.Join(s.projectDir, "event.db"), export.RestoreAuditRow{
|
||||
Action: "system.restore",
|
||||
EntityType: "backup",
|
||||
ActorID: middlewareUserID(r),
|
||||
IP: middleware.ClientIP(r),
|
||||
RequestID: middleware.GetRequestID(r.Context()),
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// trimExportPrefix 去掉 export 包错误里的包名前缀,让提示读起来像人话。
|
||||
func trimExportPrefix(message string) string {
|
||||
return strings.TrimPrefix(message, "export: ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
@@ -1462,3 +1463,352 @@ func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
|
||||
assertStoredFlags(t, "E-BOOL-OFF", 0)
|
||||
assertStoredFlags(t, "E-BOOL", 0)
|
||||
}
|
||||
|
||||
// ---------- 上传图片的删除与孤儿清理 ----------
|
||||
|
||||
// uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。
|
||||
type uploadOrphansBody struct {
|
||||
Items []uploadOrphanBody `json:"items"`
|
||||
Total int `json:"total"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
}
|
||||
|
||||
type uploadOrphanBody struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type uploadDeleteFailureBody struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type uploadDeleteBody struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Failed []uploadDeleteFailureBody `json:"failed"`
|
||||
}
|
||||
|
||||
// uploadAdminEnv 建好带项目目录的环境并登录 admin:
|
||||
// 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。
|
||||
func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) {
|
||||
t.Helper()
|
||||
env, uploadsDir := newUploadEnv(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
return env, uploadsDir, client
|
||||
}
|
||||
|
||||
// uploadOne 上传一张图片,返回 /uploads/<随机名>。
|
||||
func uploadOne(t *testing.T, env *testEnv, client *http.Client) string {
|
||||
t.Helper()
|
||||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[uploadResultBody](t, body).Path
|
||||
}
|
||||
|
||||
func uploadBaseName(path string) string {
|
||||
return path[strings.LastIndex(path, "/")+1:]
|
||||
}
|
||||
|
||||
type teamCreatedBody struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。
|
||||
func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 {
|
||||
t.Helper()
|
||||
status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
||||
"code": code, "name": name, "logoPath": logo, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[teamCreatedBody](t, body).ID
|
||||
}
|
||||
|
||||
// putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。
|
||||
func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) {
|
||||
t.Helper()
|
||||
event := map[string]any{}
|
||||
display := map[string]any{}
|
||||
switch key {
|
||||
case "event.logo_path":
|
||||
event["logoPath"] = path
|
||||
case "display.logo":
|
||||
display["logo"] = path
|
||||
case "display.background":
|
||||
display["background"] = path
|
||||
default:
|
||||
t.Fatalf("未知的引用点 %s", key)
|
||||
}
|
||||
status, body := env.do(client, http.MethodPut, "/api/settings",
|
||||
// requestId 必须带上目标值:服务端按 body.requestId 做幂等回放,
|
||||
// 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。
|
||||
map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody {
|
||||
t.Helper()
|
||||
status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[uploadOrphansBody](t, body)
|
||||
}
|
||||
|
||||
// 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。
|
||||
func TestListUploadOrphansOnlyUnreferenced(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
|
||||
eventLogo := uploadOne(t, env, client)
|
||||
teamLogo := uploadOne(t, env, client)
|
||||
free := uploadOne(t, env, client)
|
||||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 {
|
||||
t.Fatalf("应先落盘 3 张图片,实际 %v", names)
|
||||
}
|
||||
|
||||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||||
|
||||
orphans := listOrphans(t, env, client)
|
||||
if orphans.Total != 1 || len(orphans.Items) != 1 {
|
||||
t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items)
|
||||
}
|
||||
item := orphans.Items[0]
|
||||
if item.Name != uploadBaseName(free) {
|
||||
t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free))
|
||||
}
|
||||
if item.URL != free {
|
||||
t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free)
|
||||
}
|
||||
if item.Size != int64(len(pngContent())) {
|
||||
t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent()))
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil {
|
||||
t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err)
|
||||
}
|
||||
if orphans.TotalBytes != item.Size {
|
||||
t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size)
|
||||
}
|
||||
|
||||
// 把图配到大屏会标上,它就不再是孤儿。
|
||||
putImageRef(t, env, client, "display.logo", free)
|
||||
if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 {
|
||||
t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。
|
||||
func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) {
|
||||
env, _, client := uploadAdminEnv(t)
|
||||
orphans := listOrphans(t, env, client)
|
||||
if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil {
|
||||
t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。
|
||||
func TestDeleteUploadRejectsReferenced(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
|
||||
eventLogo := uploadOne(t, env, client)
|
||||
displayLogo := uploadOne(t, env, client)
|
||||
background := uploadOne(t, env, client)
|
||||
teamLogo := uploadOne(t, env, client)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||||
putImageRef(t, env, client, "display.logo", displayLogo)
|
||||
putImageRef(t, env, client, "display.background", background)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
label string
|
||||
}{
|
||||
{eventLogo, "赛事会标"},
|
||||
{displayLogo, "大屏会标"},
|
||||
{background, "大屏背景图"},
|
||||
{teamLogo, "单位「第一中学」的单位图标"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error)
|
||||
}
|
||||
if body.Error == nil || !strings.Contains(body.Error.Message, c.label) {
|
||||
t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil {
|
||||
t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 清掉引用后同一张图就能删掉,文件确实消失。
|
||||
putImageRef(t, env, client, "event.logo_path", "")
|
||||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) {
|
||||
t.Fatalf("删除后文件应消失: %v", err)
|
||||
}
|
||||
|
||||
// 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。
|
||||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound {
|
||||
t.Fatalf("删除不存在的图片应 404,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound {
|
||||
t.Fatalf("非图片扩展名应 404,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。
|
||||
func TestUploadOrphansCountsHiddenTeam(t *testing.T) {
|
||||
env, _, client := uploadAdminEnv(t)
|
||||
logo := uploadOne(t, env, client)
|
||||
teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo)
|
||||
|
||||
if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if orphans := listOrphans(t, env, client); orphans.Total != 0 {
|
||||
t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items)
|
||||
}
|
||||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict {
|
||||
t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。
|
||||
func TestDeleteUploadOrphansBatch(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
used := uploadOne(t, env, client)
|
||||
orphanA := uploadOne(t, env, client)
|
||||
orphanB := uploadOne(t, env, client)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", used)
|
||||
|
||||
names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"}
|
||||
status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
result := decodeData[uploadDeleteBody](t, body)
|
||||
if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) {
|
||||
t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted)
|
||||
}
|
||||
if len(result.Failed) != 3 {
|
||||
t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed)
|
||||
}
|
||||
reasonOf := func(name string) string {
|
||||
for _, item := range result.Failed {
|
||||
if item.Name == name {
|
||||
return item.Reason
|
||||
}
|
||||
}
|
||||
t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed)
|
||||
return ""
|
||||
}
|
||||
if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") {
|
||||
t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason)
|
||||
}
|
||||
if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") {
|
||||
t.Fatalf("缺失文件应报不存在,得到 %q", reason)
|
||||
}
|
||||
if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") {
|
||||
t.Fatalf("非法文件名应被拒,得到 %q", reason)
|
||||
}
|
||||
|
||||
for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} {
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("孤儿 %s 应已删除: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil {
|
||||
t.Fatalf("被引用的图片不能被批量删除: %v", err)
|
||||
}
|
||||
var audits int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil {
|
||||
t.Fatalf("查询删除审计: %v", err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits)
|
||||
}
|
||||
|
||||
// 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。
|
||||
if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest {
|
||||
t.Fatalf("空 names 应 400,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除与清理是系统级资源管理:guest 401、recorder 403。
|
||||
func TestUploadDeleteRequiresAdmin(t *testing.T) {
|
||||
env, uploadsDir, admin := uploadAdminEnv(t)
|
||||
orphan := uploadBaseName(uploadOne(t, env, admin))
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
|
||||
guest, _ := env.client()
|
||||
// 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。
|
||||
if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("guest 读孤儿清单应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("guest 删图片应 401,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("guest 批量删孤儿应 401,得到 %d", status)
|
||||
}
|
||||
|
||||
recorder, _ := env.client()
|
||||
env.login(recorder, "recorder", "recorder-pass")
|
||||
if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 删图片应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status)
|
||||
}
|
||||
// 权限不足的请求不能有任何副作用:那张孤儿必须还在。
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil {
|
||||
t.Fatalf("权限不足的删除请求不该动文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。
|
||||
func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
projectDir := filepath.Dir(uploadsDir)
|
||||
const secret = "outside-secret"
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatalf("创建项目目录: %v", err)
|
||||
}
|
||||
outside := filepath.Join(projectDir, "secret.png")
|
||||
if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil {
|
||||
t.Fatalf("写 uploads 之外的文件: %v", err)
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
"/api/uploads/..%2Fsecret.png",
|
||||
"/api/uploads/%2e%2e%2Fsecret.png",
|
||||
"/api/uploads/a%2Fb.png",
|
||||
}
|
||||
for _, path := range paths {
|
||||
status, _ := env.do(client, http.MethodDelete, path, nil, true)
|
||||
if status == http.StatusOK {
|
||||
t.Fatalf("%s 不应删成功", path)
|
||||
}
|
||||
if body, err := os.ReadFile(outside); err != nil || string(body) != secret {
|
||||
t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@ func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
if len(in.Results) == 0 {
|
||||
return nil, badRequest("results 不能为空", nil)
|
||||
}
|
||||
// 计分引擎不读 points_override,落库只会造成「设置成功但毫无效果」。
|
||||
// 这里直接拒绝并指向正确做法(积分调整),不做静默丢弃。
|
||||
for i, it := range in.Results {
|
||||
if it.PointsOverride != nil {
|
||||
return nil, badRequest("pointsOverride 已不再支持:该值不会被计分引擎读取,请改用「积分调整」为该单位/运动员加减分", i)
|
||||
}
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
saved, err := s.upsertDraft(r.Context(), in.EventID, middlewareUserID(r), in.Results)
|
||||
if err != nil {
|
||||
@@ -298,7 +305,8 @@ func (s *server) listAuditLogsHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
page, size := pageQuery(r)
|
||||
entityType := strings.TrimSpace(r.URL.Query().Get("entityType"))
|
||||
userID, _ := int64Query(r, "userId")
|
||||
return s.listAuditLogs(r.Context(), page, size, entityType, userID)
|
||||
action := strings.TrimSpace(r.URL.Query().Get("action"))
|
||||
return s.listAuditLogs(r.Context(), page, size, entityType, userID, action)
|
||||
}
|
||||
|
||||
func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
|
||||
@@ -177,3 +177,15 @@ type resetUserPasswordInput struct {
|
||||
Password string `json:"password"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// restoreBackupPayload 是 POST /api/backup/restore 的请求体。
|
||||
//
|
||||
// File 只接受备份目录里的裸文件名(不接受路径);Kind 是**要覆盖的那份库**
|
||||
// (备份列表里的 dbKind)——安全副本的性质是 safety,但它同样属于某一份库,
|
||||
// 拿它退回上一个时间点是正当操作,所以这里仍只认 system / event。
|
||||
// 服务端再按文件名校验一遍(对不上直接拒绝):恢复错库的代价是两份数据一起完蛋。
|
||||
type restoreBackupPayload struct {
|
||||
File string `json:"file"`
|
||||
Kind string `json:"kind"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// points_override 落库但引擎不读,属于「设置成功但毫无效果」。
|
||||
// 写入侧必须 400 并指向正确做法(积分调整);库里也不能留下任何非空值。
|
||||
func TestSaveDraftRejectsPointsOverride(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
override := 7.5
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok", "pointsOverride": override},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("带 pointsOverride 的保存应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "积分调整") {
|
||||
t.Fatalf("400 提示应指向「积分调整」,得到 %+v", env2.Error)
|
||||
}
|
||||
|
||||
var stored int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM results WHERE points_override IS NOT NULL`).Scan(&stored); err != nil {
|
||||
t.Fatalf("统计 points_override: %v", err)
|
||||
}
|
||||
if stored != 0 {
|
||||
t.Fatalf("被拒绝的请求不应落库任何 points_override,实际 %d 条", stored)
|
||||
}
|
||||
|
||||
// 不带 pointsOverride 的保存照常成功。
|
||||
status, env2 = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("不带 pointsOverride 的保存应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史数据里若真带着 points_override,计分环节必须显式报错而不是照常算出别的分。
|
||||
func TestPublishRejectsLegacyPointsOverride(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
env.exec(t, `UPDATE results SET points_override = 3 WHERE entry_id = ?`, ids.entryA)
|
||||
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/results/publish",
|
||||
map[string]any{"eventId": ids.event}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("带 points_override 的成绩发布应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "积分调整") {
|
||||
t.Fatalf("400 提示应指向「积分调整」,得到 %+v", env2.Error)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
@@ -39,6 +40,13 @@ func (s *server) broadcastChanges(next http.Handler) http.Handler {
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
changes, err := s.realtime.LoadChanges(ctx, before, 0)
|
||||
if err != nil {
|
||||
if dbClosed(err) {
|
||||
// 恢复流程会在请求末尾关掉目标库再换文件,广播中间件在这之后才读 change_log,
|
||||
// 必然撞上「库已关闭」。这是停机动作的一部分,不是故障——
|
||||
// 记 ERROR 只会让现场以为恢复把系统搞坏了。降级成 Debug,其余错误照旧。
|
||||
s.logger.Debug("跳过实时推送:库已关闭(恢复/重启中)", "err", err)
|
||||
return
|
||||
}
|
||||
s.logger.Error("读取实时变更失败", "err", err)
|
||||
return
|
||||
}
|
||||
@@ -48,10 +56,35 @@ func (s *server) broadcastChanges(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。
|
||||
//
|
||||
// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到它的变量,
|
||||
// 只能按原文认;这里独立声明一份,比对时逐层走错误链看有没有同文本的那一层。
|
||||
var errDBClosed = errors.New("sql: database is closed")
|
||||
|
||||
// dbClosed 判定错误是否只是「库已经被关掉」。
|
||||
//
|
||||
// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:
|
||||
// 别的读库失败(锁超时、SQL 写错、库损坏)都得继续当 ERROR 报出来,
|
||||
// 不能被这条静默规则顺手吞掉。
|
||||
func dbClosed(err error) bool {
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if e.Error() == errDBClosed.Error() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。
|
||||
func (s *server) changeVersion(ctx context.Context) (int64, bool) {
|
||||
var v sql.NullInt64
|
||||
if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
|
||||
if dbClosed(err) {
|
||||
// 同 broadcastChanges:库已关闭是恢复/重启的中间状态,不是故障。
|
||||
s.logger.Debug("跳过变更版本:库已关闭(恢复/重启中)", "err", err)
|
||||
return 0, false
|
||||
}
|
||||
s.logger.Error("读取变更版本失败", "err", err)
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
/**
|
||||
* 恢复流程会在请求末尾关掉目标库、换掉文件,随后进程才优雅退出。
|
||||
* 广播中间件是在 handler 返回之后读 change_log 的,必然撞上「sql: database is closed」——
|
||||
* 那不是故障,是一条 ERROR 却在现场最紧张的时刻出现,会被当成「恢复把系统搞坏了」。
|
||||
*
|
||||
* 这里钉住两条口径:库已关闭 → 只留 Debug;其它读库失败 → 照旧 ERROR,不许被静默吞掉。
|
||||
*/
|
||||
|
||||
// stubRealtime 只实现广播中间件用到的那两个方法。
|
||||
type stubRealtime struct {
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func (s *stubRealtime) Broadcast([]realtime.Change) {}
|
||||
|
||||
func (s *stubRealtime) LoadChanges(context.Context, int64, int) ([]realtime.Change, error) {
|
||||
return nil, s.loadErr
|
||||
}
|
||||
|
||||
func (s *stubRealtime) Instances() []realtime.Instance { return nil }
|
||||
|
||||
func captureLogs() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
func openSQLiteAt(t *testing.T, path string, migrate bool) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s: %v", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if migrate {
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("迁移赛事库: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// closedDBError 拿一个真正的「sql: database is closed」。
|
||||
// 标准库里这个错误是未导出的(database/sql 的 errDBClosed),只能打开一个库再关掉、
|
||||
// 然后查询它,让驱动自己把它交出来。
|
||||
func closedDBError(t *testing.T) error {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "closed.db")+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatalf("打开库: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("关闭库: %v", err)
|
||||
}
|
||||
var one int
|
||||
return db.QueryRow("SELECT 1").Scan(&one)
|
||||
}
|
||||
|
||||
// 库已关闭不是故障:changeVersion 只留 Debug,不出 ERROR。
|
||||
func TestChangeVersionClosedDBIsNotLoggedAsError(t *testing.T) {
|
||||
logger, logs := captureLogs()
|
||||
db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("关闭赛事库: %v", err)
|
||||
}
|
||||
s := &server{logger: logger, ev: db}
|
||||
|
||||
if _, ok := s.changeVersion(context.Background()); ok {
|
||||
t.Fatal("已关闭的库不该给出变更版本")
|
||||
}
|
||||
if strings.Contains(logs.String(), "level=ERROR") {
|
||||
t.Errorf("库已关闭不该记 ERROR:%s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "库已关闭") {
|
||||
t.Errorf("应留下一条 Debug 便于排查:%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 真的读不通(这里是表都不存在)仍然要记 ERROR,不能被「库已关闭」这条规则顺手吞掉。
|
||||
func TestChangeVersionOtherErrorsAreStillLogged(t *testing.T) {
|
||||
logger, logs := captureLogs()
|
||||
// 没跑过迁移的库:没有 change_log 表。
|
||||
db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false)
|
||||
s := &server{logger: logger, ev: db}
|
||||
|
||||
if _, ok := s.changeVersion(context.Background()); ok {
|
||||
t.Fatal("查不到 change_log 时不该给出变更版本")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取变更版本失败") {
|
||||
t.Errorf("真失败仍要记 ERROR:%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 广播中间件:写请求成功后 LoadChanges 撞上「库已关闭」→ 不记 ERROR。
|
||||
func TestBroadcastChangesClosedDBIsNotLoggedAsError(t *testing.T) {
|
||||
logger, logs := captureLogs()
|
||||
ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true)
|
||||
// 按 realtime 包的包装方式裹一层,确认 errors 链上的「库已关闭」也认得出。
|
||||
loadErr := fmt.Errorf("读取增量变更: %w", closedDBError(t))
|
||||
s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: loadErr}}
|
||||
|
||||
h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil))
|
||||
|
||||
if strings.Contains(logs.String(), "level=ERROR") {
|
||||
t.Errorf("库已关闭不该记 ERROR:%s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "库已关闭") {
|
||||
t.Errorf("应留下一条 Debug 便于排查:%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 广播中间件:别的读库失败(磁盘 I/O、SQL 写错…)照旧记 ERROR。
|
||||
func TestBroadcastChangesOtherErrorsAreStillLogged(t *testing.T) {
|
||||
logger, logs := captureLogs()
|
||||
ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true)
|
||||
s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: errors.New("disk I/O error")}}
|
||||
|
||||
h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil))
|
||||
|
||||
if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取实时变更失败") {
|
||||
t.Errorf("真失败仍要记 ERROR:%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// dbClosed 只认原文完全相等的那一层:子串、相似文案都不算。
|
||||
func TestDbClosedMatchesOnlyExactMessage(t *testing.T) {
|
||||
if !dbClosed(closedDBError(t)) {
|
||||
t.Error("真正的「sql: database is closed」应被认出")
|
||||
}
|
||||
if !dbClosed(fmt.Errorf("读取增量变更: %w", closedDBError(t))) {
|
||||
t.Error("被 %w 包过一层也要认出")
|
||||
}
|
||||
for _, err := range []error{
|
||||
nil,
|
||||
errors.New("database is closed"),
|
||||
errors.New("sql: database is closed (something else)"),
|
||||
errors.New("sql: database is locked"),
|
||||
errors.New("disk I/O error"),
|
||||
} {
|
||||
if dbClosed(err) {
|
||||
t.Errorf("%v 不该被当成「库已关闭」", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,83 @@ func (s *server) reviveTeam(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// teamRestore 是恢复接口的回报。
|
||||
//
|
||||
// Restored 单独报出来,是因为「本来就没被隐藏」与「这一次真的恢复了」在界面上是两句话,
|
||||
// 审计也只该为后者留痕:重复点恢复不该在审计里堆一串什么都没发生的记录。
|
||||
type teamRestore struct {
|
||||
// Before 只给审计用(被隐藏时的样子),不进响应体——响应体回答的是「它现在长什么样」。
|
||||
Before teamDTO `json:"-"`
|
||||
Team teamDTO `json:"team"`
|
||||
Restored bool `json:"restored"`
|
||||
}
|
||||
|
||||
// restoreTeam 把「彻底删除」(隐藏)的单位放回界面,是操作员显式点的那个恢复入口。
|
||||
//
|
||||
// 与 reviveTeam(导入命中编码时的顺带复活)的区别只有两点,语义本身完全一致:
|
||||
// - 先确认它真的被隐藏:没被隐藏就按幂等成功返回现状,绝不顺手把「停用」改成启用;
|
||||
// - 编码已被另一个可见单位占用时明确报 409,不让库里出现两个同 code 的可见单位。
|
||||
//
|
||||
// 隐藏时只动了 deleted_at / active 两列,报名、成绩、流水、人工调整、纪录一行未动,
|
||||
// 所以这里同样不重算、不推榜单:成功后榜单与 change_log 应逐字节不变(有测试钉着)。
|
||||
func (s *server) restoreTeam(ctx context.Context, id int64) (teamRestore, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return teamRestore{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
before, err := teamByIDQ(ctx, tx, id)
|
||||
if err != nil {
|
||||
return teamRestore{}, err
|
||||
}
|
||||
if before.DeletedAt == nil {
|
||||
return teamRestore{Before: before, Team: before, Restored: false}, nil
|
||||
}
|
||||
// 编码是唯一索引,正常不可能撞上;但有人绕过导入路径手工改库时「已隐藏的 A」与
|
||||
// 「新建的 A」会同时存在,此时恢复会造出两个同 code 的可见单位,榜单与导出都会指错行。
|
||||
var dup int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM teams WHERE code = ? AND id <> ? AND deleted_at IS NULL`,
|
||||
before.Code, id).Scan(&dup); err != nil {
|
||||
return teamRestore{}, internal(err)
|
||||
}
|
||||
if dup > 0 {
|
||||
return teamRestore{}, conflict("单位编码 " + before.Code + " 已被另一个可见单位占用,请先处理那个单位再恢复")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ?`, id); err != nil {
|
||||
return teamRestore{}, internal(err)
|
||||
}
|
||||
after, err := teamByIDQ(ctx, tx, id)
|
||||
if err != nil {
|
||||
return teamRestore{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return teamRestore{}, internal(err)
|
||||
}
|
||||
return teamRestore{Before: before, Team: after, Restored: true}, nil
|
||||
}
|
||||
|
||||
// restoreTeamHandler 处理 POST /api/teams/{id}/restore:把「彻底删除」的单位放回界面。
|
||||
//
|
||||
// 权限与彻底删除同档(recorder)——能隐藏的人就该能恢复,不额外收紧。
|
||||
func (s *server) restoreTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.restoreTeam(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 只在真的改了库时记审计:重复点恢复、或对没隐藏的单位调用,都只是幂等回放,不是事件。
|
||||
if result.Restored {
|
||||
s.audit(r.Context(), "team.restore", "team", id, result.Before, result.Team)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- athletes ----------
|
||||
|
||||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
|
||||
|
||||
@@ -675,125 +675,6 @@ func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
|
||||
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return entryDTO{}, notFound("报名项不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
|
||||
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
|
||||
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
||||
}
|
||||
status, err := normalizeEntryStatus(in.Status)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
return s.getEntry(ctx, id)
|
||||
}
|
||||
|
||||
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
||||
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
|
||||
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
|
||||
eventID, err := s.entryEventID(ctx, id)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
status, err := normalizeEntryStatus(in.Status)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
||||
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
if err := requireChanges(res, "报名项不存在"); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
return s.getEntry(ctx, id)
|
||||
}
|
||||
|
||||
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
|
||||
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
|
||||
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
|
||||
var eventID int64
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, notFound("报名项不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, internal(err)
|
||||
}
|
||||
return eventID, nil
|
||||
}
|
||||
|
||||
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
|
||||
//
|
||||
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
|
||||
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
|
||||
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
|
||||
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
|
||||
var catEvent int64
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil
|
||||
case err != nil:
|
||||
return internal(err)
|
||||
case catEvent != eventID:
|
||||
return badRequest("categoryId 不属于该小项", categoryID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
||||
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
|
||||
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return requireChanges(res, "报名项不存在")
|
||||
}
|
||||
|
||||
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
|
||||
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
|
||||
const maxEntryHeat = 999
|
||||
|
||||
@@ -1064,7 +1064,10 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st
|
||||
|
||||
// ---------- audit logs ----------
|
||||
|
||||
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) {
|
||||
// listAuditLogs 支持 entityType / userId / action 三个过滤条件,任意组合。
|
||||
// action 按子串模糊匹配:现场找的是「某类动作」(如 team.、result.publish),
|
||||
// 精确等值会逼操作员背全名。三个条件都进同一条 WHERE,组合时是 AND。
|
||||
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64, action string) (pageResult[auditLogDTO], error) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
if entityType != "" {
|
||||
@@ -1075,6 +1078,11 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
|
||||
where = append(where, "user_id = ?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
if action != "" {
|
||||
// 转义 % / _ / \ 让它们按字面量匹配,避免用户输入被当成通配符。
|
||||
where = append(where, `action LIKE ? ESCAPE '\'`)
|
||||
args = append(args, "%"+escapeLike(action)+"%")
|
||||
}
|
||||
clause := " WHERE " + joinAnd(where)
|
||||
|
||||
var total int
|
||||
@@ -1117,6 +1125,11 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
|
||||
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
// escapeLike 把 LIKE 的通配符转义为字面量,配合 ESCAPE '\' 使用。
|
||||
func escapeLike(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
|
||||
// ---------- backup ----------
|
||||
|
||||
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
|
||||
|
||||
@@ -309,6 +309,8 @@ func mapDomainError(err error) error {
|
||||
errors.Is(err, domain.ErrUnknownBasis),
|
||||
errors.Is(err, domain.ErrUnknownLedgerOp):
|
||||
return stateInvalid(err.Error())
|
||||
case errors.Is(err, domain.ErrPointsOverrideUnsupported):
|
||||
return badRequest("pointsOverride 已不再支持,请改用「积分调整」为该单位/运动员加减分", err.Error())
|
||||
case errors.Is(err, domain.ErrProfileNotFound):
|
||||
return badRequest("计分通道引用的积分模板不存在", err.Error())
|
||||
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
|
||||
|
||||
@@ -208,6 +208,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
|
||||
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
|
||||
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
|
||||
// 「彻底删除」是假删除(只标记不可见),这条把标记撤掉、单位回到界面。权限与删除同档。
|
||||
mux.HandleFunc("POST /api/teams/{id}/restore", s.write(auth.RoleRecorder, s.restoreTeamHandler))
|
||||
|
||||
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
|
||||
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
|
||||
@@ -279,6 +281,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
|
||||
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
|
||||
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
|
||||
// 恢复是停机动作:校验 + 拍安全副本 + 换文件后优雅重启,详见 restoreBackupHandler。
|
||||
mux.HandleFunc("POST /api/backup/restore", s.write(auth.RoleAdmin, s.restoreBackupHandler))
|
||||
|
||||
// 用户管理(系统配置,仅 admin;guest 读 403、写 401)
|
||||
mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler))
|
||||
@@ -295,6 +299,13 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
|
||||
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
|
||||
|
||||
// 图片的删除与孤儿清理(admin,与字体删除同一口径):上传是录入员的日常操作,
|
||||
// 删文件是系统级资源管理。删除接口复用 uploadContentType 的名字校验与防穿越;
|
||||
// 孤儿清单只列「没有任何引用点用到」的图片,批量删除只动请求里点名的文件。
|
||||
mux.HandleFunc("DELETE /api/uploads/{name}", s.writeVoid(auth.RoleAdmin, s.deleteUploadHandler))
|
||||
mux.HandleFunc("GET /api/uploads/orphans", s.read(auth.RoleAdmin, s.listUploadOrphansHandler))
|
||||
mux.HandleFunc("POST /api/uploads/orphans/delete", s.write(auth.RoleAdmin, s.deleteUploadsHandler))
|
||||
|
||||
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
|
||||
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
|
||||
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// restoreData 是恢复接口(POST /api/teams/{id}/restore)的响应体。
|
||||
type restoreData struct {
|
||||
Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
ShortName *string `json:"shortName"`
|
||||
LogoPath *string `json:"logoPath"`
|
||||
Color *string `json:"color"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Active bool `json:"active"`
|
||||
DeletedAt *string `json:"deletedAt"`
|
||||
} `json:"team"`
|
||||
Restored bool `json:"restored"`
|
||||
}
|
||||
|
||||
// standingsSnapshot 取积分榜原始响应体(json.RawMessage 的原文),供「逐字节不变」的比对。
|
||||
func standingsSnapshot(t *testing.T, env *testEnv, client *http.Client) string {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return string(env2.Data)
|
||||
}
|
||||
|
||||
// maxChangeLogID 取 change_log 的最大 id:它是实时推送的 version,只要它没动,
|
||||
// 就说明这次操作既没重算也没推榜单。
|
||||
func maxChangeLogID(t *testing.T, env *testEnv) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&id); err != nil {
|
||||
t.Fatalf("读 change_log 最大值: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// restoreTeamReq 调恢复接口(写请求要带 CSRF 头)。
|
||||
func restoreTeamReq(t *testing.T, env *testEnv, client *http.Client, id int64) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodPost, fmt.Sprintf("/api/teams/%d/restore", id), nil, true)
|
||||
}
|
||||
|
||||
// findEntry 在报名列表里找某个报名项。
|
||||
func findEntry(rows []map[string]any, id int64) map[string]any {
|
||||
for _, row := range rows {
|
||||
if row["id"] == float64(id) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countAudit 统计某个动作的审计条数(审计在响应写完后才落库,所以调用前要给它一点时间)。
|
||||
func countAudit(t *testing.T, env *testEnv, action string, entityID int64) int {
|
||||
t.Helper()
|
||||
return countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ? AND entity_id = ?`, action, entityID)
|
||||
}
|
||||
|
||||
// 恢复的核心:单位回到默认列表,且报名、成绩、流水一行没动——榜单与 change_log 逐字节不变。
|
||||
func TestRestoreTeamBringsHiddenTeamBack(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "team-restore",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 人工调整与纪录也一起造出来:恢复后它们应原样还在。
|
||||
adj := env.insert(t, `INSERT INTO point_adjustments (team_id, delta, category, reason)
|
||||
VALUES (?, 5, 'bonus', 'A 的加分')`, ids.teamA)
|
||||
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points)
|
||||
VALUES (?, ?, 'adjustment', ?, 5)`, ids.teamA, ids.event, adj)
|
||||
|
||||
standingsBefore := standingsSnapshot(t, env, client)
|
||||
logBefore := maxChangeLogID(t, env)
|
||||
entriesBefore := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA)
|
||||
ledgerBefore := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA)
|
||||
|
||||
// 先彻底删除:默认列表消失,但榜单与 change_log 都不该动。
|
||||
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) != nil {
|
||||
t.Fatalf("前置条件:彻底删除后默认列表不该有它")
|
||||
}
|
||||
if got := standingsSnapshot(t, env, client); got != standingsBefore {
|
||||
t.Fatalf("彻底删除不该改榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
if got := maxChangeLogID(t, env); got != logBefore {
|
||||
t.Fatalf("彻底删除不该写 change_log:之前 %d,之后 %d", logBefore, got)
|
||||
}
|
||||
|
||||
// 恢复。
|
||||
status, env2 := restoreTeamReq(t, env, client, ids.teamA)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[restoreData](t, env2)
|
||||
if !result.Restored {
|
||||
t.Fatalf("这次恢复真的改了库,restored 应为 true,得到 %+v", result)
|
||||
}
|
||||
// 与隐藏前完全等价:编码、全称、简称、排序都不该被这次操作碰到。
|
||||
if result.Team.ID != ids.teamA || result.Team.Code != "A" || result.Team.Name != "第一分公司" ||
|
||||
result.Team.SortOrder != 0 || result.Team.Active != true || result.Team.DeletedAt != nil {
|
||||
t.Fatalf("恢复后的单位应与隐藏前等价(active=true、deletedAt=null),得到 %+v", result.Team)
|
||||
}
|
||||
if result.Team.ShortName == nil || *result.Team.ShortName != "一司" {
|
||||
t.Fatalf("简称不该丢,得到 %+v", result.Team.ShortName)
|
||||
}
|
||||
|
||||
// 库里:清空 deleted_at 并恢复 active。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NULL AND active = 1`,
|
||||
ids.teamA); n != 1 {
|
||||
t.Fatalf("恢复应清空 deleted_at 并置 active = 1,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// 界面:默认列表里有它,非 admin 单查也不再是 404。
|
||||
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) == nil {
|
||||
t.Fatalf("恢复后应回到默认列表")
|
||||
}
|
||||
if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, false); status != http.StatusOK {
|
||||
t.Fatalf("恢复后 recorder 单查应 200,得到 %d", status)
|
||||
}
|
||||
|
||||
// 报名与成绩照旧可见:报名行还在,成绩还挂着,单位名照样补得出来。
|
||||
status, env3 := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
|
||||
if status != http.StatusOK || !env3.OK {
|
||||
t.Fatalf("读报名应 200,得到 %d %+v", status, env3.Error)
|
||||
}
|
||||
entry := findEntry(decodeData[[]map[string]any](t, env3), ids.entryA)
|
||||
if entry == nil {
|
||||
t.Fatalf("恢复后报名不该消失")
|
||||
}
|
||||
if entry["teamName"] != "第一分公司" {
|
||||
t.Fatalf("恢复后报名上的单位名应照旧,得到 %+v", entry)
|
||||
}
|
||||
res, _ := entry["result"].(map[string]any)
|
||||
if res == nil || res["resultValue"] != 11.58 || res["state"] != "published" {
|
||||
t.Fatalf("恢复后已发布成绩应照旧可见,得到 %+v", entry["result"])
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA); n != entriesBefore {
|
||||
t.Fatalf("报名不该被动:之前 %d 条,之后 %d 条", entriesBefore, n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA); n != ledgerBefore {
|
||||
t.Fatalf("积分流水不该被动:之前 %d 条,之后 %d 条", ledgerBefore, n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments WHERE team_id = ?`, ids.teamA); n != 1 {
|
||||
t.Fatalf("人工调整不该被动,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// 恢复不重算、不推榜单:榜单原文与 change_log 最大值都不变。
|
||||
if got := standingsSnapshot(t, env, client); got != standingsBefore {
|
||||
t.Fatalf("恢复不该改榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
if got := maxChangeLogID(t, env); got != logBefore {
|
||||
t.Fatalf("恢复不该写 change_log / 推榜单:之前 %d,之后 %d", logBefore, got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("A 单位总分应还是 10 + 调整 5 = 15,得到 %v", got)
|
||||
}
|
||||
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
|
||||
// 审计留下 team.restore:before 是隐藏态、after 是恢复后的样子。
|
||||
after := waitAudit(t, env, "team.restore", ids.teamA)
|
||||
if !strings.Contains(after, `"active":true`) || !strings.Contains(after, `"deletedAt":null`) {
|
||||
t.Fatalf("审计应记下恢复后的状态(active=true、deletedAt=null),得到 %s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:对没被隐藏的单位调恢复不报错、也不改库;尤其不能把「停用」顺手改成启用。
|
||||
func TestRestoreTeamIsIdempotentAndDoesNotEnable(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
logBefore := maxChangeLogID(t, env)
|
||||
|
||||
// 情况一:一直可见、启用中的单位。
|
||||
status, env2 := restoreTeamReq(t, env, client, ids.teamB)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("对没隐藏的单位调恢复应幂等成功,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[restoreData](t, env2)
|
||||
if result.Restored || !result.Team.Active || result.Team.DeletedAt != nil {
|
||||
t.Fatalf("没隐藏时应回报 restored=false 且状态不变,得到 %+v", result)
|
||||
}
|
||||
// 幂等命中不写 change_log(没有重算、没有推送)。
|
||||
if got := maxChangeLogID(t, env); got != logBefore {
|
||||
t.Fatalf("幂等命中不该写 change_log:之前 %d,之后 %d", logBefore, got)
|
||||
}
|
||||
|
||||
// 情况二:停用(软删除)是另一种状态,恢复不该成为「启用」的后门。
|
||||
// 注意软删除本身会写一条 change_log(那是停用该推的),所以重新取一次基线。
|
||||
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("停用应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
logBefore = maxChangeLogID(t, env)
|
||||
status, env2 = restoreTeamReq(t, env, client, ids.teamA)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("对停用的单位调恢复应幂等成功,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result = decodeData[restoreData](t, env2)
|
||||
if result.Restored {
|
||||
t.Fatalf("停用不是「被隐藏」,不该回报 restored=true,得到 %+v", result)
|
||||
}
|
||||
if result.Team.Active {
|
||||
t.Fatalf("恢复不能把停用的单位改成启用,得到 %+v", result)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND active = 0 AND deleted_at IS NULL`,
|
||||
ids.teamA); n != 1 {
|
||||
t.Fatalf("停用状态应原样保留,得到 %d 条", n)
|
||||
}
|
||||
if got := maxChangeLogID(t, env); got != logBefore {
|
||||
t.Fatalf("幂等命中不该写 change_log:之前 %d,之后 %d", logBefore, got)
|
||||
}
|
||||
// 幂等命中也不该留审计:什么都没发生,不该在审计里堆记录。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := countAudit(t, env, "team.restore", ids.teamB); n != 0 {
|
||||
t.Fatalf("幂等命中不该留 team.restore 审计,得到 %d 条", n)
|
||||
}
|
||||
if n := countAudit(t, env, "team.restore", ids.teamA); n != 0 {
|
||||
t.Fatalf("对停用的单位调恢复不该留 team.restore 审计,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 单位不存在:404 沿用仓库既有口径,与彻底删除一致。
|
||||
func TestRestoreMissingTeamReturns404(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := restoreTeamReq(t, env, client, 999999)
|
||||
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "单位不存在" {
|
||||
t.Fatalf("不存在的单位应 404 单位不存在,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 权限与彻底删除同档:guest 未登录 401,recorder 能删就能恢复(admin 也在 recorder 之上)。
|
||||
func TestRestoreTeamPermissionMatchesPurge(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
// 未登录请求由 RequireWrite 处理(CSRF 中间件对 guest 直接放行),所以这里不带 CSRF 头。
|
||||
guest, _ := env.client()
|
||||
if status, env2 := env.do(guest, http.MethodPost, fmt.Sprintf("/api/teams/%d/restore", ids.teamA), nil, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录调恢复应 401,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// recorder 能彻底删除,也就能恢复。
|
||||
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("recorder 彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if status, env2 := restoreTeamReq(t, env, client, ids.teamA); status != http.StatusOK {
|
||||
t.Fatalf("recorder 恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// admin 同样可以(角色包含关系,不额外收紧)。
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
adminClient, _ := env.client()
|
||||
env.login(adminClient, "admin", "admin-pass")
|
||||
if status, env2 := restoreTeamReq(t, env, adminClient, ids.teamB); status != http.StatusOK {
|
||||
t.Fatalf("admin 恢复应 200(幂等),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复不制造重复编码:编码有全局唯一索引,同 code 的第二个单位(无论可见与否)根本插不进去,
|
||||
// 所以恢复里那条「已被另一个可见单位占用」的 409 只是兜底(手工改库绕过索引时的最后一道闸)。
|
||||
func TestRestoreTeamNeverDuplicatesCode(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 已隐藏的 A 仍占着 code = 'A':再建一个同 code 的单位必须撞唯一索引。
|
||||
if _, err := env.ev.Exec(`INSERT INTO teams (code, name, sort_order, active) VALUES ('A', '冒名的 A', 9, 1)`); err == nil {
|
||||
t.Fatalf("同 code 的第二个单位不该插得进去(应撞唯一索引)")
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A'`); n != 1 {
|
||||
t.Fatalf("库里应只有一个 code = 'A' 的单位,得到 %d 个", n)
|
||||
}
|
||||
|
||||
// 恢复后仍然只有一个,且是原来那一行。
|
||||
if status, env2 := restoreTeamReq(t, env, client, ids.teamA); status != http.StatusOK {
|
||||
t.Fatalf("恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A' AND deleted_at IS NULL`); n != 1 {
|
||||
t.Fatalf("恢复后 code = 'A' 的可见单位应恰好一个,得到 %d 个", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A' AND id = ?`, ids.teamA); n != 1 {
|
||||
t.Fatalf("恢复的应是原来那一行,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user