fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

@@ -0,0 +1,55 @@
package httpapi_test
import (
"net/http"
"strings"
"testing"
)
// C12(后半):CHECK 类非法值必须走 HTTP 就落成 400,而不是一路落到 SQLite 的
// CHECK 上变成 500 + 原始约束原文。事件枚举、组别性别、通道 scope/basis、纪录 level、
// 用户 role、成绩 status 都已有用例;人工调整的 category(point_adjustments.category
// 的 CHECK)没有——它的写路由 POST /api/adjustments 到本轮为止一条用例都没有。
func TestAdjustmentCategoryEnumValidation(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
// 人工调整默认关闭,先打开:否则还没走到 category 就被「未启用」拦下,用例假绿。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
for _, bad := range []string{"bogus", "PENALTY", "罚款", " "} {
status, resp := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamA, "delta": 3, "category": bad, "reason": "测试",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("category=%q 应 400 VALIDATION_FAILED,得到 %d %+v", bad, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "category") {
t.Fatalf("错误文案应带字段名 category,得到 %q", resp.Error.Message)
}
// 撞 CHECK 的 500 会带出英文约束原文;这里必须一点都看不到。
for _, leak := range []string{"CHECK", "constraint", "SQLITE", "sqlite"} {
if strings.Contains(resp.Error.Message, leak) || strings.Contains(string(resp.Error.Detail), leak) {
t.Fatalf("错误里泄露了数据库约束原文 %q:%+v", leak, resp.Error)
}
}
}
// 被拒的请求一条都不许落库。
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 0 {
t.Fatalf("非法 category 的请求不该落库,得到 %d 行", n)
}
// 对照:三种合法类别都能落库(CHECK 允许 penalty / bonus / correction)。
for _, ok := range []string{"penalty", "bonus", "correction"} {
status, after := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamA, "delta": -1, "category": ok, "reason": "测试",
"requestId": "adj-" + ok,
}, true)
if status != http.StatusOK || !after.OK {
t.Fatalf("category=%q 应 200,得到 %d %+v", ok, status, after.Error)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 3 {
t.Fatalf("三个合法类别应各落一条,得到 %d 行", n)
}
}
@@ -1,6 +1,7 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
"time"
@@ -224,6 +225,98 @@ func TestBatchRejectsBadInput(t *testing.T) {
}
}
// batchLimit 是服务端单次批量动作的人数上限(`maxAthleteBatch`,与 `entries/batch` 的
// `maxEntryBatch` 刻意同值 500)。用例在 httpapi 包外,拿不到那个常量,只能写字面量:
// 上限一旦改动,这里与 devdocs/05 会一起变红,正好逼着同步改口径与文档。
const batchLimit = 500
// 超限(上限 + 1):400,响应体与同一个接口上既有的 400 同构,且一个人都不动。
//
// 用「上限 + 1」构造,不真发几万条:拦的是条数,与 id 具体是什么无关;
// 但条数是真做满的(全是库里存在的人),删掉上限检查后这一批会被真的停用,
// 用例只能靠状态码与文案变红,不会靠「id 不存在」之类的旁证蒙对。
func TestBatchRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit+1-len(targets))...)
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": targets}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", batchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
// 对照用「ids 不能为空」(detail 为 nil),与超限同为「只给一句话」的校验。
refStatus, ref := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": []int64{}}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(ids 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后动作:被拒的请求一个人都不能停用。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 0 {
t.Fatalf("超限被拒后不该停用任何人,得到 %d 人", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条一次全部停用。
func TestBatchAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit-len(targets))...)
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
if result.Requested != batchLimit || result.Succeeded != batchLimit || result.Failed != 0 {
t.Fatalf("正好 %d 条应全部成功,得到 %+v", batchLimit, result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != batchLimit {
t.Fatalf("应停用 %d 人,得到 %d", batchLimit, n)
}
}
// seedAthleteBatch 建 count 个运动员并返回其 id,供「正好等于上限」这类要凑够条数的用例使用。
// 整批放在一个事务里:单连接库上逐条提交会把用例拖成几百次提交。
func seedAthleteBatch(t *testing.T, env *testEnv, teamID int64, count int) []int64 {
t.Helper()
tx, err := env.ev.Begin()
if err != nil {
t.Fatalf("开事务: %v", err)
}
defer func() { _ = tx.Rollback() }()
out := make([]int64, 0, count)
for i := 0; i < count; i++ {
res, err := tx.Exec(`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
fmt.Sprintf("批量%03d", i), teamID)
if err != nil {
t.Fatalf("建运动员: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取运动员 id: %v", err)
}
out = append(out, id)
}
if err := tx.Commit(); err != nil {
t.Fatalf("提交: %v", err)
}
return out
}
// 未登录的写操作一律 401,且不改动任何数据。
func TestBatchRequiresLogin(t *testing.T) {
env := newTestEnv(t)
@@ -81,6 +81,11 @@ func TestRestorePrunesOldSafetyCopies(t *testing.T) {
if !strings.Contains(result.Message, "已清理 2 份") {
t.Errorf("回执应说明清理了几份:%q", result.Message)
}
// A19:保留份数这句话必须固定出现(用户文档只承诺「不参与 20 份滚动清理」),
// 否则「已清理 2 份」读起来像一次性事件,看不出这是个每类库只留 5 份的常态策略。
if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) {
t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message)
}
left := safetyCopiesIn(t, backupsDir, "pre-restore-event-")
if len(left) != 5 {
@@ -1,13 +1,21 @@
package httpapi_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
@@ -241,6 +249,15 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
if result.Message == "" || !strings.Contains(result.Message, result.SafetyCopy) {
t.Errorf("回执提示应带上安全副本名:%q", result.Message)
}
// A19:安全副本只留最近 N 份这件事必须固定说在回执里。用户文档只承诺「不参与 20 份滚动清理」,
// 现场很容易理解成「它会一直在」,想退回三周前那个时间点才发现早被后几次恢复的清理删了。
// 本次没有清理任何副本(pruned=0)也要说这句;没删就别说「已清理」。
if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) {
t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message)
}
if strings.Contains(result.Message, "已清理") {
t.Errorf("本次没清理任何副本,回执不该出现「已清理」:%q", result.Message)
}
// ① 当前库回到备份时刻:备份之后加的单位没了,原有两个还在。
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
@@ -276,6 +293,169 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
}
}
// probeRestore 发一个**带自身超时**的恢复请求,返回状态码与信封;超时(服务端把这个请求
// 挂住不放)时返回 error。
//
// 为什么不用 env.do:这条用例的探测请求探的正是「第二个恢复请求会不会被挂住排队」。
// 若用不带超时的请求,被测实现一旦退化成排队,探测本身就会永远等不到响应,把整个用例
// 堵死在 client.Do 里——defers 与 t.Cleanup 全都轮不到执行,于是整个包挂到 go test
// 超时(现场表现是 600s 死掉 + panic dump 指着这条用例),而不是干脆报红。
func probeRestore(client *http.Client, url, csrf string, body any, timeout time.Duration) (int, envelope, error) {
raw, err := json.Marshal(body)
if err != nil {
return 0, envelope{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/api/backup/restore", bytes.NewReader(raw))
if err != nil {
return 0, envelope{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
resp, err := client.Do(req)
if err != nil {
return 0, envelope{}, err
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
var env envelope
if len(bytes.TrimSpace(out)) > 0 {
if err := json.Unmarshal(out, &env); err != nil {
return resp.StatusCode, env, fmt.Errorf("解析响应: %w (%s)", err, out)
}
}
return resp.StatusCode, env, nil
}
// A21:恢复必须互斥。两个管理员同时点「一键恢复」时,第二个请求要**当场**拿到
// 409 +「正在恢复,请稍后」,而不是排队等着一起动库文件(两次替换交错的后果是库被换成
// 哪一份都说不清,两份回执还各说各话)。
//
// 构造:第一个请求故意把 JSON body 分两段发——它拿到互斥锁后会阻塞在解码上,
// 这正是生产里「一次恢复正在进行」的形态;此时第二个请求进来必须被挡回。
//
// 这条用例的要求不止「能抓到回归」,还有「抓到时必须干脆地失败」:它天然要留一个卡在
// 服务端的半截请求,任何一步无上限地等待,回归发生时就会变成整包挂到 go test 超时。
// 所以三处都封了上限——探测请求各自带 1s 超时(永远不会把测试主 goroutine 堵住)、
// 收尾用 t.Cleanup 主动取消第一个在飞请求并让假 body 立刻报错(服务端 handler 随即
// 返回,httptest.Server.Close() 不会等它)、最后一等也带 20s 上限。
func TestRestoreRejectsSecondRequestWhileBusy(t *testing.T) {
env, _, _ := backupRestoreEnv(t)
env.seed(t)
client := env.loginAdmin(t)
csrf := env.csrfToken(client)
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-")
// 半截 body 的恢复请求:只发一半 JSON,让它在 handler 里卡住。
pr, pw := io.Pipe()
// 这个请求要一直挂着,所以它的取消必须挂在清理路径上(断言失败时也执行):
// cancel 让客户端侧的在飞请求当场结束(连接随之断开,服务端读 body 立刻报错),
// 关掉写端再补一层——两条路任意一条走通,handler 都会立刻返回并释放互斥锁,
// 不会把 httptest.Server.Close() 卡在那里等。
ctx, cancelFirst := context.WithCancel(context.Background())
// t.Cleanup 按后进先出执行,本函数注册的比环境里的 srv.Close 晚,所以先于它跑。
t.Cleanup(func() {
cancelFirst()
_ = pw.CloseWithError(io.ErrClosedPipe)
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, env.server.URL+"/api/backup/restore", pr)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
first := make(chan struct {
status int
env envelope
}, 1)
go func() {
resp, err := client.Do(req)
if err != nil {
first <- struct {
status int
env envelope
}{status: 0}
return
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
var out envelope
_ = json.Unmarshal(raw, &out)
first <- struct {
status int
env envelope
}{status: resp.StatusCode, env: out}
}()
if _, err := io.WriteString(pw, `{"kind":"event","file":"`+eventBackup.Name+`"`); err != nil {
t.Fatalf("写半截 body: %v", err)
}
// 第二个请求:用一份**故意非法**的载荷反复试——它一旦被放行也只会拿 400(校验在任何
// 文件操作之前),不会把库改掉;拿不到 409 说明第一个请求还没进 handler,再试一次即可。
// 这样就不必靠 sleep 去赌时序。
const probeTimeout = time.Second
const deadlineWindow = 5 * time.Second
deadline := time.Now().Add(deadlineWindow)
var (
lastStatus int
lastError *apiErrorBody
queued bool
)
for {
status, resp, err := probeRestore(client, env.server.URL, csrf,
map[string]any{"file": "system-not-a-backup.txt", "kind": "system"}, probeTimeout)
if err == nil && status == http.StatusConflict {
if resp.Error == nil || !strings.Contains(resp.Error.Message, "正在恢复,请稍后") {
t.Fatalf("并发恢复的提示 = %+v,期望「正在恢复,请稍后」", resp.Error)
}
break
}
switch {
case err == nil:
// 只记第一次拿到的回应:后面反复试可能自己撞上限流(429),那反而不是要报的事实。
if lastStatus == 0 {
lastStatus, lastError = status, resp.Error
}
case errors.Is(err, context.DeadlineExceeded):
// 探测请求被服务端挂住了:正是「排队」回归的形态,记下来,等总时限到了好报清楚。
queued = true
default:
t.Fatalf("并发恢复探测请求失败: %v", err)
}
if time.Now().After(deadline) {
if queued {
t.Fatalf("第一个恢复请求占着互斥锁时,第二个恢复请求被挂住排队(%s 内没有回话);"+
"应当当场回 409「正在恢复,请稍后」,而不是排队等它做完", probeTimeout)
}
t.Fatalf("第二个恢复请求在 %s 内没拿到 409「正在恢复,请稍后」(第一次拿到的回应 status=%d %+v);"+
"要么互斥没让它撞上「正在恢复」,要么它根本没能进到互斥判断", deadlineWindow, lastStatus, lastError)
}
time.Sleep(10 * time.Millisecond)
}
// 收尾:把 body 补完,第一个请求照常做完这次恢复(回执正常,库确实被换回备份时刻)。
if _, err := io.WriteString(pw, `}`); err != nil {
t.Fatalf("补完 body: %v", err)
}
_ = pw.Close()
// 带超时地等:这一步要是无限等,被测实现的其它退化(例如恢复本身挂住)同样会把包拖到
// 测试超时,报出来的错还不如这里一句「第一个恢复请求没在 20s 内回话」。
select {
case got := <-first:
if got.status != http.StatusOK || !got.env.OK {
t.Fatalf("第一个恢复请求应正常完成,得到 status=%d %+v", got.status, got.env.Error)
}
case <-time.After(20 * time.Second):
t.Fatalf("第一个恢复请求在 20s 内没有回话(body 已补完,它本该照常做完这次恢复)")
}
}
// 恢复系统库:只换 system.db,赛事库一行不动;审计照旧写进赛事库。
func TestRestoreSystemBackupLeavesEventDBAlone(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
@@ -217,3 +217,37 @@ func TestDisplayCommandRejectsUnknownAction(t *testing.T) {
t.Fatalf("未知 action 不该下发,得到 %v", sender.sent)
}
}
// C27:announce_clear 与其它指令同路(realtime.CmdAnnounceClear 已在协议清单里),
// 不再需要「转发前先问实时层认不认」的特判。这条用例把两件事一起钉住:
// 指令要下发(否则大屏挂着旧公告),持久值也要清空(否则大屏重连又把它显示出来)。
func TestDisplayCommandAnnounceClearForwardsAndClears(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
if status, e := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "announce",
"payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30},
}, true); status != http.StatusOK {
t.Fatalf("先发一条公告应 200,得到 %d %+v", status, e.Error)
}
if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" {
t.Fatalf("公告应已落库,得到 %q", got)
}
sender.sent = nil
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "announce_clear"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清空公告应 200,得到 %d %+v", status, env2.Error)
}
if len(sender.sent) != 1 || sender.sent[0] != "announce_clear" {
t.Fatalf("清空公告必须下发给实时层,得到 %+v", sender.sent)
}
if got := settingValue(t, env, "display.announce_text"); got != "" {
t.Fatalf("清空公告应把持久值写成空串,得到 %q", got)
}
if after := waitDisplayAudit(t, env); !strings.Contains(after, `"action":"announce_clear"`) {
t.Fatalf("清空公告也要留 display.command 审计,得到 %s", after)
}
}
@@ -134,6 +134,66 @@ func TestSetEntriesHeatValidatesInput(t *testing.T) {
assertNoHeat(t, env, ids.entryA)
}
// entryHeatBatchLimit 与 POST /api/entries/batch 的 `maxEntryBatch` 刻意同值 500:
// 两个接口处理的都是「一批报名项」,上限与文案就该是同一句(服务端直接复用同一个常量)。
// 用例在 httpapi 包外拿不到常量,写字面量;上限改动时这里会红。
const entryHeatBatchLimit = 500
// 超限(上限 + 1):400,响应体与既有 400 同构,且一条报名项的批次都没改。
//
// 501 条全是同一个小项里的**真实**报名项(复用 partial_publish_test.go 的 seedBatch:
// 同样是在 seed 基础上补齐到 N 条):删掉上限检查后这一批会被真的写进库,
// 用例只能靠状态码与文案变红,不会靠「部分报名项不存在」之类的旁证蒙对。
func TestSetEntriesHeatRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit+1)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 1}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", entryHeatBatchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
refStatus, ref := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": []int64{}, "heat": 1}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(entryIds 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后写库:被拒的请求一条批次都不能落。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE heat IS NOT NULL`); n != 0 {
t.Fatalf("超限被拒后不该写任何批次,得到 %d 条", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条同小项的报名项一次写成同一批次。
func TestSetEntriesHeatAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 2}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("正好 %d 条应放行,得到 %d %+v", entryHeatBatchLimit, status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["updated"] != float64(entryHeatBatchLimit) {
t.Fatalf("应更新 %d 条,得到 %+v", entryHeatBatchLimit, result)
}
assertCount(t, env, `SELECT COUNT(*) FROM entries WHERE heat = 2`, entryHeatBatchLimit)
}
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
env, ids, _ := recorderWithSeed(t)
+7 -3
View File
@@ -2,7 +2,9 @@ package httpapi
import "strings"
// 枚举型入参的取值校验集中在本文件。
// 枚举型入参的取值校验集中在本文件(**但不全**:`entries.status` 的归一只有导入那条路走
// entry_status.go 的 normalizeImportStatus;`scope` / `basis` / `records.level` / `/api/users`
// 的 `role`,以及性别、`avgMode` 等仍在各自原处)。
//
// 这些列在迁移里都写了 CHECK (col IN (...)),但 CHECK 是最后一道闸:非法值一路落到
// SQLite 才被拒,报出来的是原始约束错误,被统一错误处理当成服务端异常,前端看到
@@ -10,8 +12,10 @@ import "strings"
//
// 每个函数都直接调用 badRequest:拦截点靠它捕获调用栈,中间再包一层错误构造 helper
// 会让拦截点退化成 response.go 里的构造函数(有测试钉着,见 intercept_log_test.go)。
// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;返回值做 TrimSpace,
// 避免「前后带空格」这种近失值又落回 CHECK。
// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;本文件里的函数返回值都做
// TrimSpace,避免「前后带空格」这种近失值又落回 CHECK。
// ⚠️ 例外:`normalizeAvgMode` 还在 repo_events.go(本文件之外),且**不做 TrimSpace**——
// 前后带空格的 `valid` / `all` 会被直接拒成 400,不会被裁掉(P2-C24b)。
// normalizeEventType 校验 events.event_type。
// 取值来自 0004 的 CHECK,0016 用 writable_schema 追加了 individual_team。
+3
View File
@@ -90,7 +90,10 @@ func TestUpdateEventEnumValidation(t *testing.T) {
client := recorderClient(t, env)
// 非法值在事务前就被拦下,不该碰库。
// 请求体带上必填字段:更新路径已与新建同口径(缺 sportId / code / name 先报「必填」),
// 这条用例钉的是枚举的拦截点,别让它被更早的必填校验挡在前面。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
"sportId": ids.sport, "code": "M100", "name": "100米跑",
"eventType": "bogus",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
@@ -4,6 +4,7 @@ import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -306,6 +307,72 @@ func TestBaseDataImportNeverDeletes(t *testing.T) {
}
}
// A20:基础数据包里带了「已彻底删除」的单位时,导入必须说清「这行被跳过了、因为单位已删除」。
// 旧行为是照常计入「无变化 / 更新」并写库:现场点预览看到「更新 1 条」,确认写入后界面上仍然
// 没有这个单位(它还是隐藏状态),看不出任何解释,像是导入没生效。
func TestBaseDataImportSkipsPurgedTeamAndSaysWhy(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 彻底删除 B 单位:假删除——deleted_at 非空、active=0,业务数据一行不动。
status, resp := env.do(client, http.MethodDelete,
fmt.Sprintf("/api/teams/%d?purge=1", ids.teamB), nil, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, resp.Error)
}
pack := buildTestXLSX(t, []testSheet{{
name: "单位",
rows: [][]string{
{"单位编码", "单位名称"},
{"B", "第二分公司(导入改名)"},
},
}})
// 预览:该行必须单列一条说明,且不计进「新增 / 更新 / 无变化」。
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.Updated != 0 || res.Unchanged != 0 {
t.Errorf("命中已删除单位的那行不该计入变更:新增 %d / 更新 %d / 无变化 %d",
res.Added, res.Updated, res.Unchanged)
}
if res.ErrorCount != 1 || len(res.Errors) != 1 {
t.Fatalf("应单列 1 条说明,得到 错误 %d %v", res.ErrorCount, res.Errors)
}
// 文案里只给界面路径:这条会直接显示给现场操作员,不该出现内部接口路径。
for _, want := range []string{"第 2 行", "B", "已被彻底删除", "跳过", "基础数据 → 参赛单位", "恢复"} {
if !strings.Contains(res.Errors[0], want) {
t.Errorf("说明 %q 应包含 %q", res.Errors[0], want)
}
}
if strings.Contains(res.Errors[0], "/api/") {
t.Errorf("给现场看的说明不该带内部接口路径:%q", res.Errors[0])
}
// 落地:同样跳过,不改它的字段(改了名它照样是隐藏的,现场只会更困惑)。
res = importPackage(t, env, client, pack, nil)
if res.ErrorCount != 1 || res.Updated != 0 {
t.Fatalf("落地也应跳过该行,得到 错误 %d / 更新 %d %v", res.ErrorCount, res.Updated, res.Errors)
}
var (
name string
deleted sql.NullString
activeIn int
)
if err := env.ev.QueryRow(`SELECT name, deleted_at, active FROM teams WHERE id = ?`, ids.teamB).
Scan(&name, &deleted, &activeIn); err != nil {
t.Fatalf("读单位: %v", err)
}
if name != "第二分公司" {
t.Errorf("单位名 = %q,期望仍是「第二分公司」(隐藏单位的字段一行都不该被改)", name)
}
if !deleted.Valid || activeIn != 0 {
t.Errorf("隐藏状态被改动了:deleted_at=%v active=%d", deleted, activeIn)
}
}
// 新建的单位 / 大项不能被建成停用:缺省 = 启用。
func TestBaseDataImportCreatesEnabledRows(t *testing.T) {
env := newTestEnv(t)
@@ -0,0 +1,252 @@
package httpapi_test
import (
"strconv"
"testing"
)
// C34:「列缺席 / 格子空 / 显式值」三态口径按铁律每一列都要有对照用例。
//
// 本轮之前只有排序与布尔列有这三组对照;权重 / 年龄 / 文本列没有——
// 而这三类各自的口径并不一样(都来自文档写死的行为):
//
// 列缺席 = 不动该字段(四张表、所有列一律如此)
// 格子空(权重) = 取默认值 1
// 格子空(年龄) = 「不限」(清成 NULL)
// 格子空(文本) = 清空
// 显式值 = 照写
//
// 三态本来是分两轮踩出来的坑(「缺列把全部停用改回启用」「留空被重置成默认」),
// 每一列都必须自己钉住,否则改一列的口径不会有人发现。
type baseDataTriStateColumn struct {
sheet string
header string
seed string // 库里先改成这个非默认值
blank string // 格子空之后的期望库值
explicit string // 表格里显式填的合法值
want string // 显式填值之后的期望库值(数值列会归一化)
rows int // 这张表里会被整列改写影响到的数据行数(单位表有两行,其余各一行)
apply func(t *testing.T, env *testEnv, ids seedIDs)
read func(t *testing.T, env *testEnv, ids seedIDs) string
}
// affectedRows 是整列改写会改动到的行数:改一列就是改这张表每一行的这一列。
func (c baseDataTriStateColumn) affectedRows() int {
if c.rows <= 0 {
return 1
}
return c.rows
}
// textCol 读某表某列的文本现值(NULL 按空串)。
func textCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v string
if err := env.ev.QueryRow(
`SELECT COALESCE(`+col+`, '') FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
return v
}
}
// floatCol 读某表某列的数值现值;用与差异报告同一个格式化口径(formatFloat 的 %g),
// 这样「2.5 与 2.50 是同一个值」不会让断言假红。
func floatCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v float64
if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
}
// intCol 读某表某列的可空整数现值(NULL 按空串 = 不限)。
func intCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v *int
if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
if v == nil {
return ""
}
return strconv.Itoa(*v)
}
}
// idsFor 把「表 → 种子行的 id」这件事收在一处:三张表在种子里各只有一行。
func idsFor(table string, ids seedIDs) int64 {
switch table {
case "teams":
return ids.teamA
case "events":
return ids.event
default:
return ids.category
}
}
// triStateColumns 是本条要覆盖的三态列:权重(项目与组别)、年龄上下限、场地 / 备注 / 颜色三列文本。
func triStateColumns() []baseDataTriStateColumn {
return []baseDataTriStateColumn{
{
sheet: "小项", header: "权重", rows: 1,
seed: "2.5", blank: "1", explicit: "3.5", want: "3.5",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET multiplier = 2.5 WHERE id = ?`, ids.event)
},
read: floatCol("events", "multiplier"),
},
{
sheet: "组别", header: "权重", rows: 1,
seed: "4", blank: "1", explicit: "0.5", want: "0.5",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET multiplier = 4 WHERE id = ?`, ids.category)
},
read: floatCol("event_categories", "multiplier"),
},
{
sheet: "组别", header: "年龄下限", rows: 1,
seed: "30", blank: "", explicit: "12", want: "12",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category)
},
read: intCol("event_categories", "age_min"),
},
{
sheet: "组别", header: "年龄上限", rows: 1,
seed: "40", blank: "", explicit: "45", want: "45",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category)
},
read: intCol("event_categories", "age_max"),
},
{
sheet: "小项", header: "场地", rows: 1,
seed: "田径场A", blank: "", explicit: "副场", want: "副场",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET venue = '田径场A' WHERE id = ?`, ids.event)
},
read: textCol("events", "venue"),
},
{
sheet: "小项", header: "备注", rows: 1,
seed: "雨后备注", blank: "", explicit: "改过的备注", want: "改过的备注",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET remark = '雨后备注' WHERE id = ?`, ids.event)
},
read: textCol("events", "remark"),
},
{
// 单位表有两行数据:整列改写会同时动到两个单位,期望条数跟着行数走。
sheet: "单位", header: "颜色", rows: 2,
seed: "#FF0000", blank: "", explicit: "#00FF00", want: "#00FF00",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE teams SET color = '#FF0000' WHERE id = ?`, ids.teamA)
env.exec(t, `UPDATE teams SET color = '#0000FF' WHERE id = ?`, ids.teamB)
},
read: textCol("teams", "color"),
},
}
}
func TestBaseDataImportTriStateColumns(t *testing.T) {
for _, col := range triStateColumns() {
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
// 三个期望值必须两两不同,否则「不做区分」的实现也能让用例全绿。
if col.seed == col.blank || col.seed == col.want || col.blank == col.want {
t.Fatalf("三态期望值必须两两不同:seed=%q blank=%q explicit=%q", col.seed, col.blank, col.want)
}
t.Run("列缺席=不动", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
dropColumn(t, f, col.sheet, col.header)
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)
}
assertNoFieldDiff(t, res, col.header)
res = importPackage(t, env, client, pack, nil)
if res.Added != 0 || res.Updated != 0 {
t.Fatalf("落地后也不该有改动:新增 %d / 更新 %d", res.Added, res.Updated)
}
if got := col.read(t, env, ids); got != col.seed {
t.Fatalf("「%s」缺列后库里应保持原值 %q,得到 %q", col.header, col.seed, got)
}
})
t.Run("格子空=按本列口径", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteColumn(t, f, col.sheet, col.header, "")
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.ErrorCount != 0 || res.Added != 0 {
t.Fatalf("格子空不该报错、也不该新建:新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
// 预览必须先报出这一列的差异再说:预览说「无变化」而落地改了,是最坏的一种不一致。
// 三态期望值两两不同(上面断言过),所以格子空必然产生差异。
if res.Updated != col.affectedRows() {
t.Fatalf("格子空应报 %d 条更新,得到 %d %+v", col.affectedRows(), res.Updated, res.Changes)
}
assertNoFieldDiff(t, res, "单位名称", "大项名称", "小项名称", "组别名")
res = importPackage(t, env, client, pack, nil)
if res.ErrorCount != 0 {
t.Fatalf("落地不该报错,得到 %v", res.Errors)
}
if got := col.read(t, env, ids); got != col.blank {
t.Fatalf("「%s」格子空后库里应为 %q(本列口径),得到 %q", col.header, col.blank, got)
}
})
t.Run("显式值=照写", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteColumn(t, f, col.sheet, col.header, col.explicit)
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.ErrorCount != 0 || res.Added != 0 || res.Updated != col.affectedRows() {
t.Fatalf("显式填 %q 应报 %d 条更新、无错误,得到 新增 %d / 更新 %d / 错误 %d %v",
col.explicit, col.affectedRows(), res.Added, res.Updated, res.ErrorCount, res.Errors)
}
res = importPackage(t, env, client, pack, nil)
if res.Updated != col.affectedRows() {
t.Fatalf("落地应更新 %d 条,得到 %d", col.affectedRows(), res.Updated)
}
if got := col.read(t, env, ids); got != col.want {
t.Fatalf("「%s」显式填 %q 后库里应为 %q,得到 %q", col.header, col.explicit, col.want, got)
}
// 再导入一次同样的包:已经等于目标值,必须回到「无变化」。
res = importPackage(t, env, client, pack, nil)
if res.Updated != 0 {
t.Fatalf("同一份包再导一次应无变化,得到 %d %+v", res.Updated, res.Changes)
}
})
})
}
}
@@ -52,10 +52,12 @@ func TestExportContentDispositionHasASCIIFallback(t *testing.T) {
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", ".xlsx"},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", ".xlsx"},
{"/api/export/all-flat.xlsx", "all-flat.xlsx", "赛事成绩总表(单页).xlsx", ".xlsx"},
{"/api/export/medals.xlsx", "medals.xlsx", "团体奖牌明细.xlsx", ".xlsx"},
{"/api/export/medals.xlsx", "medals.xlsx", "单位奖牌明细.xlsx", ".xlsx"},
{"/api/export/athletes.xlsx", "athletes.xlsx", "个人积分总明细.xlsx", ".xlsx"},
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", ".xlsx"},
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", ".xlsx"},
{"/api/export/entries.xlsx", "entries.xlsx", "报名名单.xlsx", ".xlsx"},
{"/api/export/basedata.xlsx", "basedata.xlsx", "基础数据包.xlsx", ".xlsx"},
{"/api/export/project.zip", "project.zip", "赛事项目包.zip", ".zip"},
}
for _, tc := range cases {
+2 -2
View File
@@ -95,8 +95,8 @@ func TestExistingExportsUnchanged(t *testing.T) {
file string
sheets []string
}{
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"}},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
+80 -14
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -24,8 +25,27 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// maxProjectImportBytes 是赛事包上传的硬上限。
//
// **故意与另外两个批量导入(名单 CSV 32MB、基础数据包 32MB)不同**:赛事包是唯一
// 带二进制资源的包——库里导出 + 赛事会标 / 大屏背景图 / 单位 logo,以及**字体**
// (字体上传的单文件上限本身就是 20MB,见 fonts.go 的 maxFontBytes,且允许传多个)。
// 按 32MB 卡会让「两个字体 + 一张背景图」的正常赛事包直接导不进来,属于生产事故。
//
// 真正防「整个包驻留内存」的是 ParseMultipartForm 的 maxMemory(见 multipartMemoryBytes):
// 超出部分 Go 会自动落到临时文件,所以上限放宽不等于内存放大。
//
// 它是 var 而不是 const,只为给测试留一个「临时调小再验拒绝路径」的口子
// (见 SetMaxProjectImportBytesForTest);生产代码没有任何改动它的入口。
var maxProjectImportBytes int64 = 512 << 20
// SetMaxProjectImportBytesForTest 临时调整赛事包上限,返回原值便于 defer 还原。
// 仅供测试使用;调用方不要在 t.Parallel() 的用例里用它。
func SetMaxProjectImportBytesForTest(v int64) int64 {
prev := maxProjectImportBytes
maxProjectImportBytes = v
return prev
}
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
@@ -34,7 +54,7 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
// 单位奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
@@ -135,13 +155,13 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
})
}
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
// exportMedalsXLSX 导出单位奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return s.streamDownload(w, "medals.xlsx", "单位奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteMedalDetailXLSX(out, wb)
})
}
@@ -249,7 +269,7 @@ type baseDataChange struct {
// baseDataImportResult 是差异预览 / 导入落地的结果。
// 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数;
// errorCount 是另计的被拦下的行(它们不写库)。
// errorCount 是另计的被拦下的行(它们不写库),命中「已彻底删除」的单位也在这一档里。
type baseDataImportResult struct {
DryRun bool `json:"dryRun"`
Added int `json:"added"`
@@ -270,7 +290,7 @@ type baseDataImportResult struct {
// 表里没有的行一律不动——导入永远不做删除。
func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
@@ -325,6 +345,8 @@ type baseDataExistingTeam struct {
color *string
sortOrder int
active bool
// deleted 表示这行是「已彻底删除」(teams.deleted_at 非空)的单位:库里有、界面上没有。
deleted bool
}
type baseDataExistingSport struct {
@@ -495,8 +517,10 @@ func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDat
func (p *baseDataPlanner) loadExisting() error {
// 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。
// 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。
// deleted_at 也一并读出来:命中隐藏单位时要把它跳掉并明确告诉用户(见 planTeams),
// 只按「更新」走的话现场改完名字界面上仍然没有它,看起来像导入没生效。
teamRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT id, code, name, short_name, color, sort_order, active
SELECT id, code, name, short_name, color, sort_order, active, deleted_at IS NOT NULL
FROM teams ORDER BY sort_order, code`)
if err != nil {
return internal(err)
@@ -508,11 +532,12 @@ func (p *baseDataPlanner) loadExisting() error {
code string
shortName, color sql.NullString
)
var active int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil {
var active, deleted int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active, &deleted); err != nil {
return internal(err)
}
t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0
t.deleted = deleted != 0
p.teams[code] = t
}
if err := teamRows.Err(); err != nil {
@@ -623,6 +648,16 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
if !ok {
existing = baseDataExistingTeam{active: true}
}
// 命中「已彻底删除」(deleted_at 非空)的单位:库里那行还在、界面上和导出件里都没有它。
// 旧行为是照常走「更新 / 无变化」并真的写库,于是现场会看到「更新 1 条」却怎么也找不到这个单位
// (它仍是隐藏状态),像是导入没生效。改成整行跳过 + 单独报一句原因与恢复入口。
// 文案只指界面路径:这句话会直接显示给现场操作员,内部接口路径对他们没有意义。
if ok && existing.deleted {
p.rowError(sheet, line,
"单位 %s 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」后点「恢复」",
code)
continue
}
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)
@@ -1400,9 +1435,17 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
// multipartMemoryBytes 是所有 multipart 上传共用的**内存档位**(不是大小上限):
// 解析时最多在内存里留这么多,超出部分 Go 自动落到临时文件。
// 防「整个包驻留内存」靠的是它,所以各入口的大小上限可以按内容类型各不相同。
const multipartMemoryBytes = 16 << 20
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
// 内存里只留 multipartMemoryBytes,超出的部分由 multipart 落在系统临时目录。
// 注意别把 maxMemory 与上面的大小上限搞混:上限可以宽(赛事包带字体/图片),
// 内存档位必须窄。
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
@@ -1496,6 +1539,19 @@ type backupRestoreResult struct {
Warning string `json:"warning,omitempty"`
}
// restoreMu 串行化「一键恢复」。
//
// 恢复动的是数据目录里的库文件本身(关连接 → 整文件替换 → 触发重启),
// 同一次操作里没有任何一步能并发安全地跑两遍:两个管理员同时点(连点两次、两个窗口、
// 两个人各按一次)会让两次替换交错——一个刚拍完安全副本、另一个已经换了文件,
// 回执还会各说各话(一个说已用 A 恢复、另一个说已用 B 恢复),而现场只看到「恢复了」。
//
// 与备份侧 export.Service.mu 是同一套做法(同一时刻只让一个动库文件的操作在跑),
// 区别在这里是全局一把:要保护的是数据目录里的库文件,它不属于某一个 server 实例。
// 后来者不排队静默执行——排队会让请求一直挂着,页面上的「正在重启」会变成假象,
// 所以用 TryLock 当场返回「正在恢复,请稍后」。
var restoreMu sync.Mutex
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
//
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
@@ -1510,6 +1566,12 @@ type backupRestoreResult struct {
// → 关连接、替换文件
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
// 整段串行化(含参数校验):忙的时候直接回话,连文件都不碰。
if !restoreMu.TryLock() {
return nil, conflict("正在恢复,请稍后:上一次恢复还在进行,等它重启完成、页面恢复后再试")
}
defer restoreMu.Unlock()
var in restoreBackupPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
@@ -1629,10 +1691,14 @@ func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (a
} else {
base += ";服务端正在重启,请稍候刷新页面"
}
notes := []string{"恢复前安全副本:" + safety.Name}
// 安全副本自己那份保留策略要固定说清楚:用户文档只承诺「不参与 20 份滚动清理」,
// 现场很容易理解成「这些副本会一直在」。实际上每类库只留最近 DefaultSafetyCopyKeep 份,
// 第 N 次恢复会把最早的删掉——不写死这句话,想退回三周前那个时间点时会扑空。
// 但只在真清理了才说「已清理 N 份」:没删就说删了也是假话。
notes := []string{fmt.Sprintf("恢复前安全副本:%s(每类库保留最近 %d 份,更旧的会被后续恢复清理)",
safety.Name, export.DefaultSafetyCopyKeep)}
if pruned > 0 {
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
pruned, export.DefaultSafetyCopyKeep))
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本", pruned))
}
if kind == export.RestoreKindEvent {
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
@@ -67,3 +67,25 @@ func TestExportProjectNameMessageIsActionable(t *testing.T) {
t.Errorf("超长名称应提示长度上限,得到 %q", got)
}
}
// D06:赛事包的上传**上限**故意高于另外两个批量导入,但**内存驻留**必须与它们同档。
//
// 背景:名单 CSV 与基础数据包是纯文本/表格,32MB 上限够用;赛事包是唯一带二进制
// 资源的包——赛事会标 / 大屏背景图 / 单位 logo / **字体**(单文件上限就有 20MB,
// 且允许多个)。所以上限不能按 32MB 卡(`两个字体 + 一张背景图` 的正常包会被拒),
// 真正要防的是「整个包驻留内存」,那由 ParseMultipartForm 的 maxMemory 决定:
// 超出 maxMemory 的部分 Go 会自动落到临时文件。
//
// 这条用例钉两件事:①内存档位不许被单独放大;②上限不许被收回到与纯文本导入同档。
func TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous(t *testing.T) {
if multipartMemoryBytes > 32<<20 {
t.Errorf("multipart 内存档位 = %d,超过 32MB 就得重新权衡内存占用", multipartMemoryBytes)
}
if maxProjectImportBytes < 128<<20 {
t.Errorf("赛事包上限 = %d,低于 128MB 会让带字体/图片的正常赛事包导不进来", maxProjectImportBytes)
}
if maxEntryImportBytes > 64<<20 || maxBaseDataImportBytes > 64<<20 {
t.Errorf("纯文本导入的上限不该超过 64MB:名单 = %d,基础数据包 = %d",
maxEntryImportBytes, maxBaseDataImportBytes)
}
}
@@ -66,6 +66,55 @@ func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []by
return resp.StatusCode, raw
}
// D06:超过上限的赛事包必须在解析阶段就被挡住(400 + 中文提示),
// 超限赛事包必须在读进内存/走完导入流程之前就被拒绝。
//
// ⚠️ 这里把上限**临时调小**再验拒绝路径:赛事包的上限故意放宽到 512MB(它是唯一带
// 字体/图片等二进制资源的包,字体单文件上限就有 20MB),拿真的 512MB 请求体做用例
// 太重;而「上限够宽」由 TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous
// 与下面那条 40MB 的用例钉住。
func TestImportProjectZipRejectsOversizeUpload(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
original := httpapi.SetMaxProjectImportBytesForTest(1 << 20)
defer httpapi.SetMaxProjectImportBytesForTest(original)
// 2MB:内容不重要,超限本身就够了(MaxBytesReader 会在解析 multipart 时截断请求体)。
oversize := bytes.Repeat([]byte("x"), 2<<20)
status, raw := env.postProjectImport(t, client, oversize, nil)
if status != http.StatusBadRequest {
t.Fatalf("超限赛事包应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "解析上传失败") {
t.Errorf("超限提示 = %s,期望「解析上传失败」", raw)
}
}
// 40MB 的赛事包(超过纯文本导入那档 32MB)必须**过得了大小闸门**:
// 带两个字体 + 一张背景图的正常赛事包就是这个量级,按 32MB 卡会让现场导不进去。
// 这里只断言「不再因体积被拒」,内容仍是假 zip,所以会走到后面的格式校验报错。
func TestImportProjectZipAcceptsPackagesAboveTextImportLimit(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
body := bytes.Repeat([]byte("x"), 40<<20)
status, raw := env.postProjectImport(t, client, body, nil)
if status == http.StatusBadRequest && strings.Contains(string(raw), "解析上传失败") {
t.Fatalf("40MB 赛事包不该因体积被拒(上限必须容得下带字体/图片的包):%s", raw)
}
}
// 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。
func TestImportProjectZipRejectsRunningProject(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
@@ -53,7 +53,7 @@ func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
}
}
// guest 能下载团体奖牌明细;每块奖牌对应一条成绩。
// guest 能下载单位奖牌明细;每块奖牌对应一条成绩。
func TestExportMedalsXLSXGuest(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
@@ -64,16 +64,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body)
}
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "团体奖牌明细.xlsx")
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "单位奖牌明细.xlsx")
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
// 单位 B 只有银牌也有分表;两个单位都有奖牌 → 总表 + 2 张分表。
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalSheetList(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
@@ -94,16 +94,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
t.Errorf("分表小计 = %q", got)
}
// 与团体奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。
// 与单位奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。
_, _, standingsBody := env.getRaw(t, client, "/api/export/standings.xlsx")
sf := openWorkbook(t, standingsBody)
defer func() { _ = sf.Close() }()
medalRows, err := sf.GetRows("团体奖牌榜")
medalRows, err := sf.GetRows("单位奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
if got := masterCell(medalRows, 4, 3); got != "1" {
t.Errorf("团体奖牌榜首队金牌 = %q,期望 1", got)
t.Errorf("单位奖牌榜首队金牌 = %q,期望 1", got)
}
}
+67 -16
View File
@@ -70,11 +70,18 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
// 登出是真删会话行(服务端强制下线),属于写操作,得和登录 / 改密一样留审计:
// 现场问「谁在什么时候把谁的会话撤了」时,只有这几条动作能对上。
// 访客没有会话可撤,不记(否则 audit_logs 里会出现 user_id 为空的行)。
if u.Role != auth.RoleGuest {
s.audit(r.Context(), "auth.logout", "user", u.ID, nil, map[string]any{"username": u.Username})
}
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
return nil, nil
@@ -105,6 +112,20 @@ func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
csrf = c.Value
}
if csrf == "" {
// 会话有效但 CSRF Cookie 不在了(浏览器清理、升级/换端口后残留、手工拷了会话 Cookie):
// 双提交方案下没有它,所有写操作会永久 403,而唯一的自愈路径是「退出后重新登录」——
// 刷新页面走的还是这条路径,补不回来。这里就地签发一枚新 token 并下发,
// 会话与 token 的绑定关系没变(token 不落库、只做双提交比对),不影响任何安全口径。
token, err := auth.NewCSRFToken()
if err != nil {
// crypto/rand 失败已经意味着整个进程不可信,但 /auth/me 是只读接口,
// 不能因为补票失败就把它变成 500:记下来,本次不补发。
s.logger.Error("补发 CSRF Cookie 失败", "err", err)
} else {
csrf = token
}
}
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
return out, nil
}
@@ -125,7 +146,10 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if len(in.NewPassword) < 8 {
// 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来,
// 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节——
// bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。
if utf8.RuneCountInString(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
if len(in.NewPassword) > auth.MaxPasswordBytes {
@@ -563,13 +587,20 @@ func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (a
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
// 漏传 lastPlaceZero 不能当成「用户把最后一名归零关掉了」:PUT 是全量覆盖语义,
// 而 Go 的 bool 零值就是 false——只想改个名字的调用方会把这一项静默关掉,
// 算分行为跟着变,界面上却看不出是谁改的(同族的 active 已按这个口径处理)。
if !keys.has("lastPlaceZero") {
in.LastPlaceZero = before.LastPlaceZero
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
@@ -755,7 +786,10 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
// 审计动作一律以 entity_type 的单数族名打头:本族已有 entry.heat / entry.scratch /
// entry.restore / entry.remove,导入原先写成 entries.import 自成第二族,
// 现场按文档示例填 `entry.` 一筛会漏掉两条导入审计(清单 B01)。
s.audit(r.Context(), "entry.import", "entry", 0, nil, result)
return result, nil
}
@@ -789,7 +823,7 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
s.audit(r.Context(), "entry.import.resolve", "entry", 0, nil, result)
return result, nil
}
@@ -987,6 +1021,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
// 空串有专门含义:登记者没有证件号(个人行没填,或本来就是团体行),复用它一律按重号处理。
bibOwners := make(map[string]string)
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
@@ -1066,11 +1101,29 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}
}
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
idCard := cell("id_card")
bib := cell("bib")
if bib != "" {
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
// 团体行没有「同一个人」这回事(它没有证件号可言),因此一律按重号处理。
owner := idCard
if athleteName == "" {
owner = ""
}
if prev, seen := bibOwners[bib]; seen && (owner == "" || prev != owner) {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
bibOwners[bib] = owner
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
@@ -1083,15 +1136,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if bib != "" {
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
bibOwners[bib] = idCard
}
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
duplicate := false
@@ -1141,7 +1185,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Bib: nilIfEmpty(bib),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
@@ -1153,6 +1197,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
case outcome.Created:
result.Created++
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
for _, moved := range outcome.MovedEntries {
result.Notices = append(result.Notices, fmt.Sprintf(
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
}
default:
result.Updated++
if outcome.StatusChanged {
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -554,7 +555,7 @@ func TestImportResolveKeepExisting(t *testing.T) {
var audit int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entry.import.resolve'`).Scan(&audit); err != nil {
t.Fatalf("统计审计: %v", err)
}
if audit == 0 {
@@ -598,6 +599,61 @@ func TestImportResolveKeepNew(t *testing.T) {
}
}
// B01:报名这一族的审计动作必须共用单数前缀 entry.(导入、批次、批量退赛/恢复/移出都算)。
// 用例真发三个写请求,再走日志页同一条查询(`/api/audit-logs?action=entry.`,子串模糊)
// 断言三条都在 —— 只看字符串常量的话,改回 entries.import 这类回归照样能绿。
func TestEntryAuditActionsShareOnePrefix(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// ① 报名表导入(新增一个人 + 一条报名)。
body := importHeader + "\nM100,男子,A,前缀用例选手,110101199001019999,男,,X900,,registered,\n"
if status, env2 := env.postImport(t, client, "/api/entries/import", body, ""); status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
// ② 批量设置批次。
if status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
}
// ③ 批量退赛。
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryB}, "reason": "前缀用例",
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
}
want := []string{"entry.import", "entry.heat", "entry.scratch"}
// /api/audit-logs 是 admin-only;审计在响应之后落库,轮询到齐再断言。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
found := map[string]bool{}
for i := 0; i < 100 && len(found) < len(want); i++ {
_, env2 := env.do(adminClient, http.MethodGet, "/api/audit-logs?action=entry.&size=200", nil, false)
items, _ := decodeData[map[string]any](t, env2)["items"].([]any)
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
action, _ := item["action"].(string)
if slices.Contains(want, action) {
found[action] = true
}
}
if len(found) < len(want) {
time.Sleep(10 * time.Millisecond)
}
}
for _, action := range want {
if !found[action] {
t.Fatalf("按 entry. 前缀应能筛到 %s,实际只筛到 %+v", action, found)
}
}
}
// decisions 非法时返回 400。
func TestImportResolveRejectsBadDecisions(t *testing.T) {
env, _, client := recorderWithSeed(t)
+6 -9
View File
@@ -228,10 +228,6 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
// ---------- 大屏控制与运维 ----------
// cmdAnnounceClear 是清空当前公告的指令;实时层的协议清单(realtime.ValidDisplayAction)还没登记它,
// 因此转发前要先看实时层认不认,见 displayCommandHandler。
const cmdAnnounceClear = "announce_clear"
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
// payload 由 JSON 解出,缺失的字段按零值处理,由落库侧补默认时长。
func announceFromPayload(payload any) (string, int) {
@@ -260,9 +256,9 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
return nil, badRequest("action 必填", nil)
}
matched := 0
// 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收),
// 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
// 大屏读到靠的是 settings + change_log,与这一次广播无关。
if s.display != nil {
n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets)
if err != nil {
// 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。
@@ -281,7 +277,8 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
}
}
// 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」,
// 新连上的大屏也拿不到当前公告。
// 新连上的大屏也拿不到当前公告。清空公告走同一份持久值(写空串),
// 否则大屏重连后会把已经撤掉的公告再显示一遍。
actorID := middlewareUserID(r)
switch in.Action {
case realtime.CmdAnnounce:
@@ -289,7 +286,7 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
if err := s.saveAnnouncement(r.Context(), text, seconds, actorID); err != nil {
return nil, err
}
case cmdAnnounceClear:
case realtime.CmdAnnounceClear:
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
return nil, err
}
@@ -222,8 +222,8 @@ func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetai
}, nil
}
// parentSnapshot 返回父节点快照:优先用显式 parent_id;
// 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。
// parentSnapshot 返回父节点快照:优先用显式 parent_id(recordRevision 写入的「同小项上一条版本」);
// parent_id 为 NULL(每个小项的第一条版本、以及本轮改动之前的老数据)时,退化为按 id 找「同小项上一条记录」。
func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) {
var (
raw string
+8 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
@@ -138,7 +139,10 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt
if err != nil {
return userListItemDTO{}, err
}
if len(in.Password) < 8 {
// 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来,
// 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节——
// bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。口径与改密接口一致(见 handlers_config.go)。
if utf8.RuneCountInString(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
if len(in.Password) > auth.MaxPasswordBytes {
@@ -228,7 +232,9 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa
return resetPasswordResultDTO{}, internal(err)
}
generated = true
} else if len(password) < 8 {
} else if utf8.RuneCountInString(password) < 8 {
// 与建号、改密同一口径:下限按字符(rune)算——3 个汉字是 9 字节,
// 按字节判会把前端(按字符数)拒绝的口令放进来;上限仍按字节(bcrypt 只吃前 72 字节)。
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
} else if len(password) > auth.MaxPasswordBytes {
return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"testing"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
@@ -42,3 +43,74 @@ func TestUserPasswordLengthLimits(t *testing.T) {
t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error)
}
}
// 「至少 8 位」按字符(rune)算,不是按字节:3 个汉字是 9 字节,
// 按字节判会把前端(按字符数)拒绝的口令放进来,同一口令界面拒、接口收。
// 上限仍按字节——bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。
// 口径必须与改密接口(handlers_config.go)完全一致(P2 A37 同源缺口)。
func TestUserPasswordLengthCountsRunes(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 3 个汉字 + 5 个字母 = 8 字符 / 14 字节:字数够,必须放行。
eightRunes := "密码呀abcde"
if utf8.RuneCountInString(eightRunes) != 8 || len(eightRunes) != 14 {
t.Fatalf("用例构造错误:应 8 字符 / 14 字节,得到 %d 字符 / %d 字节",
utf8.RuneCountInString(eightRunes), len(eightRunes))
}
// 7 个汉字 = 7 字符 / 21 字节:字数不够,必须拒(按字节判的老口径会放行)。
sevenRunes := strings.Repeat("汉", 7)
// 25 个汉字 = 25 字符 / 75 字节:字数够但超 bcrypt 上限,必须拒。
tooManyBytes := strings.Repeat("汉", 25)
status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-runes", "password": eightRunes, "role": "recorder",
}, true)
if status != http.StatusOK {
t.Fatalf("8 字符(14 字节)的密码建号应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
id := int64(created["id"].(float64))
if id == 0 {
t.Fatalf("建号响应缺少 id: %+v", created)
}
// 建号的密码能直接登录,说明服务端真的按这一串收下了(没有被截断成别的口令)。
// 用新 client:带会话再登录要过 CSRF,而这里要的正是「换个人重新登」。
fresh, _ := env.client()
env.login(fresh, "u-runes", eightRunes)
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-short", "password": sevenRunes, "role": "recorder",
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" {
t.Fatalf("7 个汉字是 7 字符,建号应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-long", "password": tooManyBytes, "role": "recorder",
}, true)
if status != http.StatusBadRequest || env2.Error == nil ||
env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("25 个汉字是 75 字节,建号应 400 字节上限,得到 %d %+v", status, env2.Error)
}
// 重置密码与建号同口径:8 字符放行、7 字符拒绝。
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": eightRunes}, true)
if status != http.StatusOK {
t.Fatalf("8 字符(14 字节)的密码重置应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": sevenRunes}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" {
t.Fatalf("7 个汉字是 7 字符,重置应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": tooManyBytes}, true)
if status != http.StatusBadRequest || env2.Error == nil ||
env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("25 个汉字是 75 字节,重置应 400 字节上限,得到 %d %+v", status, env2.Error)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
"请求被拦截",
"无权访问该资源",
"请先登录",
"CSRF 校验失败,请刷新页面重试",
"CSRF 校验失败,请退出后重新登录",
"请先修改初始密码",
"internal/middleware/middleware.go:",
} {
+343
View File
@@ -0,0 +1,343 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// cookieValue 读 jar 里某个 Cookie 的当前值;没有则返回空串。
func (e *testEnv) cookieValue(t *testing.T, client *http.Client, name string) string {
t.Helper()
u, err := url.Parse(e.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == name {
return c.Value
}
}
return ""
}
// ---------- A38:/auth/me 在 CSRF Cookie 丢失时补发 ----------
// 会话有效但这台机器没有 CSRF Cookie 时,写操作会永久 403,而刷新页面走的还是 /auth/me,
// 补不回 Cookie。修法是就地签发一枚新 token 并下发——会话本身不轮换。
func TestMeReissuesMissingCSRFCookie(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, jar := env.client()
env.login(client, "admin", "admin-pass")
session := env.cookieValue(t, client, auth.CookieSession)
// 模拟丢票:浏览器清理、换端口 / 换域名后残留、手工拷了会话 Cookie。
u, _ := url.Parse(env.server.URL)
jar.SetCookies(u, []*http.Cookie{{Name: auth.CookieCSRF, Value: "", MaxAge: -1, Path: "/"}})
if got := env.csrfToken(client); got != "" {
t.Fatalf("前置条件:此时 jar 里不该还有 CSRF Cookie,得到 %q", got)
}
status, header, body := env.getRaw(t, client, "/api/auth/me")
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d:%s", status, body)
}
var setCookie bool
for _, v := range header.Values("Set-Cookie") {
if strings.Contains(v, auth.CookieCSRF+"=") {
setCookie = true
}
}
if !setCookie {
t.Fatalf("/auth/me 必须补发 CSRF Cookie,响应头是 %v", header.Values("Set-Cookie"))
}
if env.csrfToken(client) == "" {
t.Fatal("补发的 CSRF Cookie 应落进 jar 且有值")
}
// 补的是票,不是新登录:会话 Cookie 不能被换掉。
if got := env.cookieValue(t, client, auth.CookieSession); got != session {
t.Fatalf("补票不该轮换会话:%s → %s", session, got)
}
// 新票立即可用:带上它写就通过(这正是「能看不能写」的自愈路径)。
if status, resp := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"event": map[string]any{"name": "补票后写入"}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("持补发票的写请求应通过,得到 %d %+v", status, resp.Error)
}
}
// ---------- A37:密码下限按字符判 ----------
// 「至少 8 位」是按字符说的,前端也按字符数判;按字节判会放行 7 个汉字这种
// 界面明确拒绝的口令,同一口令两个口径。上限仍按字节(bcrypt 只吃 72 字节)。
func TestChangePasswordLengthCountsRunes(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 7 个汉字 = 21 字节:按字节判会放行,必须按字符拒掉。
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七"}, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Message != "新密码至少 8 位" {
t.Fatalf("7 个字符的口令应 400「新密码至少 8 位」,得到 %d %+v", status, body.Error)
}
// 25 个汉字 = 75 字节:字符数够,但超过 bcrypt 的 72 字节上限,仍要拒。
long := strings.Repeat("汉", 25)
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": long}, true)
if status != http.StatusBadRequest || body.Error == nil ||
body.Error.Message != fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("超 72 字节应 400 字节上限,得到 %d %+v", status, body.Error)
}
// 失败的改密不该动会话:紧接着还能再改一次。
// 8 个汉字 = 24 字节:字符与字节都合法,收下。
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七八"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("8 个字符的口令应被接受,得到 %d %+v", status, body.Error)
}
var hash string
if err := env.sys.QueryRow(`SELECT password_hash FROM users WHERE username = 'admin'`).Scan(&hash); err != nil {
t.Fatalf("读密码哈希: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("一二三四五六七八")); err != nil {
t.Fatalf("落库的应是这串汉字口令: %v", err)
}
}
// ---------- A05:漏传 lastPlaceZero 不能静默变成 false ----------
func TestUpdateProfileKeepsLastPlaceZeroWhenOmitted(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-LPZ-PUT", "name": "最后一名归零", "lastPlaceZero": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建模板应 200,得到 %d %+v", status, resp.Error)
}
id := int64(decodeData[map[string]any](t, resp)["id"].(float64))
path := fmt.Sprintf("/api/scoring-profiles/%d", id)
// 只想改名字的 PUT:漏传 lastPlaceZero 必须沿用库里的值,而不是被 bool 零值写成 false。
status, resp = env.do(client, http.MethodPut, path,
map[string]any{"code": "P-LPZ-PUT", "name": "改名不改开关"}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改模板应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("漏传 lastPlaceZero 不该把它写成 0,库里是 %d", got)
}
// 显式传 false 才是真的关掉。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式关掉", "lastPlaceZero": false,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 false 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 0 {
t.Fatalf("显式 false 应落库为 0,库里是 %d", got)
}
// 显式传 true 再打开。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式打开", "lastPlaceZero": true,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 true 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("显式 true 应落库为 1,库里是 %d", got)
}
}
func profileLastPlaceZero(t *testing.T, env *testEnv, id int64) int {
t.Helper()
var v int
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE id = ?`, id).Scan(&v); err != nil {
t.Fatalf("读 last_place_zero: %v", err)
}
return v
}
// ---------- A11:号码布查重必须覆盖团体行 ----------
// 团体行(athlete_name 为空)原先完全不参与号码布查重:填了号码布既不登记也不检查,
// 同一张表里两个团体行共用一个号码布会静默落库。
func TestImportChecksBibOnTeamRows(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{
name: "两个团体行共用一个号码布",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3001,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3001,,registered,王五/赵六\n",
},
{
name: "团体行与个人行共用一个号码布(团体行在前)",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3002,,registered,张三/李四\n" +
"M100,男子,B,张三,,男,,B3002,,registered,\n",
},
{
name: "团体行与个人行共用一个号码布(个人行在前)",
body: importHeader + "\n" +
"M100,男子,A,张三,,男,,B3003,,registered,\n" +
"M100,男子,B,,,男,,B3003,,registered,王五/赵六\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, resp := env.postImport(t, client, "/api/entries/import", tc.body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应落库、重号行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
t.Fatalf("应报号码布重复,得到 %+v", errs)
}
})
}
// 号码布不同的团体行照常导入,且号码布真的落进报名行。
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,,,男,,B3101,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3102,,registered,王五/赵六\n"
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[map[string]any](t, resp); result["created"] != float64(2) || result["skipped"] != float64(0) {
t.Fatalf("号码布不重复的团体行都该落库: %+v", result)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries WHERE bib IN ('B3101', 'B3102')`).Scan(&n); err != nil {
t.Fatalf("统计报名号码布: %v", err)
}
if n != 2 {
t.Fatalf("团体行的号码布应写进报名,得到 %d 行", n)
}
}
// ---------- C14:登出要写审计 ----------
func TestLogoutWritesAudit(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
var actorID sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.logout' ORDER BY id DESC LIMIT 1`).Scan(&actorID); err != nil {
t.Fatalf("登出会真删会话行,必须写审计: %v", err)
}
if !actorID.Valid || actorID.Int64 != userID {
t.Fatalf("审计应记在真实用户 %d 上,得到 %+v", userID, actorID)
}
// 访客没有会话可撤:不记(否则 audit_logs 里会出现 user_id 为空的行)。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/auth/logout", nil, false); status != http.StatusOK {
t.Fatalf("访客登出应 200,得到 %d", status)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout'`).Scan(&n); err != nil {
t.Fatalf("统计登出审计: %v", err)
}
if n != 1 {
t.Fatalf("访客登出不该写审计,得到 %d 条", n)
}
}
// ---------- A39:反代改写 Host 时的登录(端到端) ----------
// 现场场景:nginx 把 Host 改写成回源地址(127.0.0.1:port),浏览器带的 Origin 是外部域名。
// 只要直连对端在可信代理白名单里、并透传了原 Host,登录就该照常成功;
// 反过来,没配可信代理时服务端不采信任何代理头——同源校验不放水。
func TestLoginBehindTrustedProxyWithForwardedHost(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
forwarded := map[string]string{
"Origin": "http://scoreboard.local",
"X-Forwarded-Host": "scoreboard.local",
}
// 没配可信代理:r.Host(回源地址)与 Origin 不一致 → 403,提示指向反代配置。
status, resp := env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusForbidden || resp.Error == nil || resp.Error.Code != "FORBIDDEN" {
t.Fatalf("未配置可信代理时应 403(不采信代理头),得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "Host") {
t.Fatalf("提示应说明 Host 与 Origin 不一致,得到 %q", resp.Error.Message)
}
// 把直连对端登记为可信代理:代理透传的原 Host 与 Origin 一致 → 登录放行。
if err := middleware.SetTrustedProxies([]string{"127.0.0.1"}); err != nil {
t.Fatalf("配置可信代理: %v", err)
}
t.Cleanup(func() { _ = middleware.SetTrustedProxies(nil) })
status, resp = env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusOK || !resp.OK {
t.Fatalf("可信代理透传原 Host 时登录应成功,得到 %d %+v", status, resp.Error)
}
if env.cookieValue(t, client, auth.CookieSession) == "" {
t.Fatal("登录成功应下发会话 Cookie")
}
}
// ---------- C15:登录限流不再是死配置 ----------
// 迁移 0005 种下的 security.login_rate_per_minute 必须真的被读(此前全仓无人引用)。
func TestLoginRateSettingFromMigrationIsRead(t *testing.T) {
env := newTestEnv(t)
var seeded string
if err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, auth.LoginRateKey).Scan(&seeded); err != nil {
t.Fatalf("迁移应种下 %s: %v", auth.LoginRateKey, err)
}
if seeded != strconv.Itoa(auth.DefaultLoginPerMinute) {
t.Fatalf("迁移种下的值应与内置默认一致,得到 %q", seeded)
}
// 「重启后」(新起一个 Service)读库里的值:改成 2 之后第 3 次登录就该被限流。
if _, err := env.sys.Exec(`UPDATE settings SET value = '2' WHERE key = ?`, auth.LoginRateKey); err != nil {
t.Fatalf("改登录限流设置: %v", err)
}
svc := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
for i := 0; i < 2; i++ {
if !svc.AllowLogin("127.0.0.1") {
t.Fatalf("第 %d 次应放行", i+1)
}
}
if svc.AllowLogin("127.0.0.1") {
t.Fatal("第 3 次应被限流——库里的登录限流设置没有被读到")
}
}
@@ -0,0 +1,109 @@
package httpapi_test
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// C21:写限流的 429 是「中间件拦下的」——它不经 httpapi 的 writeAPIError,
// 走的是 middleware.intercept。此前只有人肉验证,没有用例盯着它是否真的落进
// interceptions/:拦截点写错、类别标记漏传、乃至 429 干脆不记日志,都不会有人发现。
//
// 用真实日志装配(logging.New 写临时目录)而不是内存缓冲:要验的正是「落哪个目录」。
func TestRateLimitInterceptionLandsInInterceptions(t *testing.T) {
logDir := t.TempDir()
handler, closer := logging.New(logging.Options{
Dir: logDir, Level: slog.LevelInfo, Console: io.Discard, MaxSizeMB: 20, KeepFiles: 10,
})
t.Cleanup(func() { _ = closer.Close() })
logger := slog.New(handler)
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = logger
o.Auth = auth.New(o.System, auth.Options{
Logger: logger,
BcryptCost: bcrypt.MinCost,
WritePerMinute: 2,
})
})
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for i := 1; i <= 2; i++ {
if status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": fmt.Sprintf("T%d", i), "name": "阈值内的单位"}, true); status != http.StatusOK {
t.Fatalf("第 %d 次写应在阈值内放行,得到 %d %+v", i, status, body.Error)
}
}
status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": "T9", "name": "超限单位"}, true)
if status != http.StatusTooManyRequests {
t.Fatalf("超出阈值应 429,得到 %d %+v", status, body.Error)
}
inter := readHTTPAPILog(t, logDir, logging.DirInterceptions)
// WARN 是默认 info 级别下现场能看到的那行:为什么没让他写 + 是谁拦的 + 什么码。
for _, want := range []string{
`"level":"WARN"`,
"请求被拦截",
"操作过于频繁,请稍后再试",
`"code":"RATE_LIMITED"`,
`"actor":"recorder#`,
"internal/middleware/middleware.go:",
"RateLimitWrite",
} {
if !strings.Contains(inter, want) {
t.Fatalf("429 的拦截日志应含 %q,得到:\n%s", want, inter)
}
}
// DEBUG 那行补上下文:现场要拿它判断是哪个账号、哪条路径、哪台机器在刷。
for _, want := range []string{`"level":"DEBUG"`, "拦截上下文", `"path":"/api/teams"`, `"code":"RATE_LIMITED"`} {
if !strings.Contains(inter, want) {
t.Fatalf("429 的拦截上下文应含 %q,得到:\n%s", want, inter)
}
}
// 429 不该被记成 5xx:它是一次有意为之的拦截,不是服务端异常。
if strings.Contains(inter, `"level":"ERROR"`) {
t.Fatalf("429 不该出现 ERROR 级日志,得到:\n%s", inter)
}
// 拦截类不能落 operations/:两类混在一起,「今天被拦过几次、为什么」就查不出来了。
ops := readHTTPAPILog(t, logDir, logging.DirOperations)
for _, unwanted := range []string{"请求被拦截", "拦截上下文"} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations/ 不该含 %q,得到:\n%s", unwanted, ops)
}
}
// 反向对照:放行的那两次写仍要落 operations/,别把两类日志整体搞反。
if !strings.Contains(ops, "操作已放行") {
t.Fatalf("放行的写操作应落 operations/,得到:\n%s", ops)
}
}
// readHTTPAPILog 读某类日志今天的文件内容;文件不存在返回空串。
func readHTTPAPILog(t *testing.T, dir, category string) string {
t.Helper()
path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
+64 -4
View File
@@ -222,7 +222,10 @@ func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
}
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
// 编码与名字落库前先裁掉首尾空白:编码是导入按 team_code 匹配的键,
// 「 A 」与「A」肉眼一样却互不相等(UNIQUE 按字节比),现场只会看到「单位不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
@@ -260,6 +263,12 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
// 更新与新建同口径:必填校验 + 裁空白。漏了这一段,一次 PUT 就能把单位的 code/name
// 清成空串或纯空格(编码是导入的匹配键,清掉之后导入再也认不出这个单位)。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
@@ -441,7 +450,8 @@ func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
}
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
// 与 isUniqueViolation 的区别不是「谁更严」,而是**按索引名认人**:这里只看错误串里有没有
// "athletes.id_card",好把证件号冲突的文案与号码布冲突(isBibConflict)及其它唯一冲突分开。
func isIDCardConflict(err error) bool {
if err == nil {
return false
@@ -639,6 +649,8 @@ func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, erro
defer func() { _ = tx.Rollback() }()
// 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。
// 这个局部 scope 与下面的包级 purgeScope 完全同构(P2-C24c)——合并不改任何行为,
// 但属于代码改动,本轮只按注释记一笔,留给下一轮统一。
type scope struct{ eventID, categoryID int64 }
scopeRows, err := tx.QueryContext(ctx,
`SELECT DISTINCT e.event_id, e.category_id
@@ -753,6 +765,17 @@ const (
athleteBatchPurge = "purge"
)
// maxAthleteBatch 是单次批量动作的人数上限,与 maxEntryBatch(报名项,entries_batch.go)同量级:
// 前端一次勾选几十人是常态,500 足够覆盖「一个单位全选」,再大说明是脚本误用或误粘贴。
//
// 不直接复用 maxEntryBatch:那个常量从名字到文案都是「报名项」,拿它去限「人」会把两类对象说混,
// 操作员照着提示去报名表里找问题只会更糊涂。数值刻意保持一致,同族的批量接口用同一把尺子,
// 前端不必为每个接口记不同的分批阈值(上限值本身的分批依据与 entries_batch.go 的那条注释同源)。
//
// 没有这层上限时,几万条 id 会先被拼成一条 IN (...) 的巨型 SQL:撞穿 SQLite 的变量上限,
// 报出来是 500,而且单连接库会被这条 SQL 长时间占住(审查 D03 实测 4 万条即触发)。
const maxAthleteBatch = 500
// athleteBatchItem 是批量动作里逐条的结果。
//
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
@@ -852,6 +875,11 @@ func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int6
if len(ids) == 0 {
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
}
// 上限拦在「读库 / 开事务」之前,与 entries_batch.go:73 同一个位置口径:
// 超限的请求不该先查一遍 id 是否存在、再拼出一条巨 SQL 去占住单连接。
if len(ids) > maxAthleteBatch {
return athleteBatchResult{}, badRequest(fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", maxAthleteBatch), nil)
}
if action == athleteBatchPurge {
return s.batchPurgeAthletes(ctx, ids, dryRun)
}
@@ -1109,7 +1137,8 @@ func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
}
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
@@ -1119,6 +1148,12 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
// default_profile_id 指向 scoring_profiles(id)(迁移 0004):指到不存在的模板时,
// 不认这个外键错就报 500「服务端异常」,而同一类错在 events.sport_id、channels.profile_id
// 上都是 400。按 badRequest 报出来,现场才知道该去改哪个字段。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
if err != nil {
return sportDTO{}, internal(err)
}
@@ -1130,6 +1165,11 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error)
}
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
// 更新与新建同口径:必填校验 + 裁空白,否则一次 PUT 就能把大项编码清成空串。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
WHERE id = ?`,
@@ -1137,6 +1177,10 @@ func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportD
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
// 同新建:改到一个不存在的模板 id 上是 400(字段名 + 原值),不是 500。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
if err != nil {
return sportDTO{}, internal(err)
}
@@ -1247,7 +1291,8 @@ func validateAwardRanks(awardRanks int) error {
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tieMode, err := normalizeTieMode(in.TieMode)
@@ -1300,6 +1345,13 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
// 与新建同口径。这里不是「顺手补个校验」:清空 code 的模板会让 admin 的
// GET /api/rules/export 与 project.zip 永久 500(rules/validate.go 仍要求 code 非空),
// 而 PUT 是 recorder 档——低级角色能把管理员的导出打成不可用,且没有任何提示。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
@@ -1352,10 +1404,18 @@ func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
// 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE,
// 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。
// 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。
seen := make(map[int]bool, len(rules))
for _, r := range rules {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
if seen[r.Rank] {
return badRequest(fmt.Sprintf("积分名次 %d 重复", r.Rank), r)
}
seen[r.Rank] = true
if _, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
profileID, r.Rank, r.Points); err != nil {
+43 -1
View File
@@ -92,7 +92,11 @@ func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
// 与单位 / 大项 / 模板同口径:必填校验前先裁掉首尾空白,落库的也是裁过的值。
// 编码是基础数据包按 event_code 匹配的键,带空格的编码与肉眼相同的正常编码并存时,
// 导入只会报「小项不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
@@ -159,6 +163,12 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
// 与新建同口径:更新路径此前完全不校验,一次 PUT 就能把小项编码清成空串
// (规则包/项目包导出与导入匹配都会因此出错),且错误要到很久以后才暴露。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
@@ -298,6 +308,9 @@ func multiplierOrOne(v float64) float64 {
}
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
//
// 它没跟其它枚举校验一起进 enums.go,也不在比对前 TrimSpace:`" valid"` 这类值会被当成
// 非法直接拒成 400,而不是裁掉空格收下(P2-C24b)。
func normalizeAvgMode(v string) (string, error) {
switch v {
case "":
@@ -312,6 +325,11 @@ func normalizeAvgMode(v string) (string, error) {
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
// 只读接口也先确认小项存在:否则它给 200 空数组,而同一件事在整表替换上是
// 404 / 500 / 400 三种答案(见 replaceCategories 的注释)。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
@@ -364,6 +382,13 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
}
defer func() { _ = tx.Rollback() }()
// 先确认小项存在:这是同一个「小项不存在」的四分之三——空表走到末尾 bumpEvent 才是 404,
// 非空表会一路撞 event_categories.event_id 的外键,报成 500「服务端异常」。
// 在同一个事务里读,免得检查完、DELETE 前对方把小项删掉。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
@@ -577,6 +602,10 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
// 与 listCategories 同口径:小项不存在就 404,不给「200 空数组」。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals
@@ -625,6 +654,12 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
}
defer func() { _ = tx.Rollback() }()
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
@@ -778,6 +813,13 @@ func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int
if len(ids) == 0 {
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
}
// 与 POST /api/entries/batch 同一把尺子、同一句文案(直接复用 maxEntryBatch):
// 两个接口处理的都是「一批报名项」,上限不该各说各话。
// 位置与 entries_batch.go:73 一致——拦在任何一次读库 / 写库之前,先于下面那条
// IN (…) 的巨型 SQL,几万条 id 的请求不会被拼进 SQL 再被拒。
if len(ids) > maxEntryBatch {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
}
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
}
@@ -0,0 +1,66 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// openEventDBForRepoTest 建一个跑过迁移的赛事库,外键按生产口径打开(DSN 里的 foreign_keys(1))。
func openEventDBForRepoTest(t *testing.T) *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 db
}
func assertNotFound(t *testing.T, err error, what string) {
t.Helper()
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("%s 应返回业务错误,得到 %v", what, err)
}
if api.status != http.StatusNotFound || api.message != "小项不存在" {
t.Fatalf("%s 应 404「小项不存在」,得到 %d %s", what, api.status, api.message)
}
}
// 整表替换这一层自己也要拦住不存在的小项。
//
// HTTP 层现在会先经 listCategories / listChannels 拿到 404(handler 读 before),
// 所以这一条在端到端用例里看不到差别;但 repo 函数是独立入口,不能靠「调用方恰好先读了一次」
// 兜底——漏了它,非空表会撞外键报 500、空表则要走到末尾 bumpEvent 才是 404。
func TestReplaceCategoriesAndChannelsRejectMissingEvent(t *testing.T) {
s := &server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), ev: openEventDBForRepoTest(t)}
ctx := context.Background()
const missing = 99999
_, err := s.replaceCategories(ctx, missing, []categoryDTO{{Name: "男子", Multiplier: 1}})
assertNotFound(t, err, "replaceCategories(非空表)")
_, err = s.replaceCategories(ctx, missing, nil)
assertNotFound(t, err, "replaceCategories(空表)")
_, err = s.replaceChannels(ctx, missing, []channelDTO{{Scope: "athlete", Basis: "result", ProfileID: 1}})
assertNotFound(t, err, "replaceChannels(非空表)")
_, err = s.replaceChannels(ctx, missing, nil)
assertNotFound(t, err, "replaceChannels(空表)")
}
@@ -0,0 +1,86 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 「小项不存在」在组别 / 通道这一族接口上必须只有一个答案:404「小项不存在」。
//
// 改前是四个:组别整表替换(非空表)撞外键报 500;组别整表替换(空表)走到末尾
// bumpEvent 才是 404;通道整表替换报 400「profileId 不存在」(profileId 本身没问题,
// 缺的是小项,照着提示去查积分模板只会白费功夫);两个列表接口给 200 空数组。
func TestNonexistentEventIsNotFoundOnCategoryAndChannelEndpoints(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
const missing = 99999
cats := fmt.Sprintf("/api/events/%d/categories", missing)
chans := fmt.Sprintf("/api/events/%d/channels", missing)
for _, tc := range []struct {
label string
method string
path string
body any
}{
{"组别列表", http.MethodGet, cats, nil},
{"通道列表", http.MethodGet, chans, nil},
{"组别整表替换(非空表)", http.MethodPut, cats,
map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}},
{"组别整表替换(空表)", http.MethodPut, cats,
map[string]any{"categories": []map[string]any{}}},
{"通道整表替换(非空表)", http.MethodPut, chans,
map[string]any{"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}}}},
{"通道整表替换(空表)", http.MethodPut, chans,
map[string]any{"channels": []map[string]any{}}},
} {
status, resp := env.do(client, tc.method, tc.path, tc.body, true)
if status != http.StatusNotFound || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s 对不存在的小项应 404,得到 %d %+v", tc.label, status, resp.Error)
}
if resp.Error.Message != "小项不存在" {
t.Fatalf("%s 的 404 文案应是「小项不存在」,得到 %q", tc.label, resp.Error.Message)
}
}
// 对照:真小项上照常可用,免得这条校验把合法请求一起挡掉。
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/categories", ids.event), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的组别列表应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "P2A-CTRL", "name": "对照小项",
"eventType": "individual", "resultMode": "auto", "resultType": "time",
"compare": "asc", "scored": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建对照小项应 200,得到 %d %+v", status, resp.Error)
}
ctrl := int64(decodeData[map[string]any](t, resp)["id"].(float64))
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", ctrl), map[string]any{
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的组别整表替换应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的通道整表替换应 200,得到 %d %+v", status, resp.Error)
}
// 通道整表替换的「profileId 不存在」仍要指对字段:小项存在、模板不存在才是 400 指向 profileId。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 99999}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "profileId") {
t.Fatalf("模板不存在应 400 并点明 profileId,得到 %d %+v", status, resp.Error)
}
}
+215 -15
View File
@@ -53,8 +53,8 @@ type athleteImportMatch struct {
Skip bool
}
// sameNameNewPerson 是「身份字段不同 → 按同名的另一个人新建」的清单项:
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
// sameNameNewPerson 是「新建的档案与库中已有的同名人员疑似重复」的清单项:
// 导入响应里单独列出来给现场核对,避免新建了档案却毫无提示。
// 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。
type sameNameNewPerson struct {
Name string `json:"name"`
@@ -63,8 +63,22 @@ type sameNameNewPerson struct {
ExistingIDCard string `json:"existingIdCard"`
NewBib string `json:"newBib"`
ExistingBib string `json:"existingBib"`
// Reason 说明为什么判成「另一个人」:identity = 证件号 / 号码布两边都有值且对不上;
// team = 这个单位下一个人都没有,但别的单位下有同名档案(多半是换了单位)。
// 现场核对的重点完全不同,所以两者要分得开。
Reason string `json:"reason"`
// ExistingTeamCode 是库中那条同名档案的单位编码(换单位场景要核对的就是它)。
ExistingTeamCode string `json:"existingTeamCode"`
}
// sameNameReason* 是 sameNameNewPerson.Reason 的取值。
const (
// sameNameReasonIdentity:表里的证件号(或号码布)与库中那条同名档案对不上 —— 多半是号写错了。
sameNameReasonIdentity = "identity"
// sameNameReasonTeam:本次这个单位下查不到同名的,但别的单位下有 —— 换了单位(也可能真是两个人同名)。
sameNameReasonTeam = "team"
)
// existingAthlete 是库中已有人员的可比字段。
type existingAthlete struct {
ID int64
@@ -110,7 +124,7 @@ func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow,
}
// matchByNameTeam 处理「证件号与号码布都没命中」的情况,按姓名 + 单位找候选:
// 0 条 → 新建;1 条且身份字段有一边没填或都对得上 → 命中并回填;
// 0 条 → 新建(并核对别的单位下有没有同名档案);1 条且身份字段有一边没填或都对得上 → 命中并回填;
// 1 条但两边都有值且都对不上 → 同名的另一个人,新建并报告;多条 → 仍有歧义,进冲突面板等用户决策。
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
@@ -119,7 +133,19 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
}
switch len(candidates) {
case 0:
return s.createImportedAthlete(ctx, in)
created, err := s.createImportedAthlete(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
// 换了单位的人正是走到这一支:这个单位下查不到同名的,别的单位下却有一个。
// 不能替用户认人(同名不同单位往往真是两个人),但更不能悄悄多建一份档案 ——
// 两份档案各自带报名,成绩与积分从此分家(清单 A13)。
reported, err := s.sameNameElsewhere(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
case 1:
ex := candidates[0]
// 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」:
@@ -130,14 +156,11 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(ex.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(ex.Bib),
reported, err := s.sameNameReport(ctx, in, ex, sameNameReasonIdentity)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
@@ -146,6 +169,41 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
}
}
// sameNameReport 组装一条「新建的档案与库中这条同名人员疑似重复」的报告。
// existing 是库中那条同名档案(同单位的候选,或跨单位找到的那条),reason 说明为什么怀疑重复。
func (s *server) sameNameReport(ctx context.Context, in athleteImportRow, existing existingAthlete, reason string) (*sameNameNewPerson, error) {
code, err := s.teamCodeByID(ctx, existing.TeamID)
if err != nil {
return nil, err
}
return &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(existing.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(existing.Bib),
Reason: reason,
ExistingTeamCode: code,
}, nil
}
// sameNameElsewhere 找库中同名、但不在本次这个单位下的人员,用于报告「换了单位」造成的新建档案。
// 只报第一条(与多人候选只以第一条为代表同一口径):现场要核对的是「这个名字是不是本来就该是
// 同一个人」,列一串同名人反而看不出重点。
func (s *server) sameNameElsewhere(ctx context.Context, in athleteImportRow) (*sameNameNewPerson, error) {
ex, err := scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id <> ? ORDER BY id LIMIT 1`,
in.Name, in.TeamID))
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, internal(err)
}
return s.sameNameReport(ctx, in, ex, sameNameReasonTeam)
}
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
@@ -329,8 +387,12 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
if ex.Name != in.Name {
changed = append(changed, "name")
}
// 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
// 性别与证件号/号码布同一口径(清单 A09):表里没填(含填了 男/女 以外的值)= 不参与比较;
// 库中为空 = 待回填,不算差异。之前是「库里为空就算差异」,一份没填性别列的旧名单会让
// 所有库里性别为空的人全掉进冲突面板(这些人本来还在静默回填证件号/号码布/年龄),
// 而性别又从不回填 —— 现场只能逐条点「使用新数据」,与 devdocs/05 §7 的匹配表相反。
// 只有两边都有值且不同(男 ↔ 女)才是真冲突。
if in.Gender != "" && ex.Gender != nil && *ex.Gender != "" && *ex.Gender != in.Gender {
changed = append(changed, "gender")
}
// 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。
@@ -354,7 +416,7 @@ func identityDiffers(dbVal *string, inVal string) bool {
return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal
}
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄/性别,已有值不动(差异已由 changedAthleteFields 判过)。
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil {
@@ -379,6 +441,13 @@ func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthl
return internal(err)
}
}
// 性别与证件号/号码布/年龄一并回填:历史名单里性别为空是常态,口径就是「库中为空 = 待回填」,
// 不回填就等于把这批人永远留在空性别上,下一份表再导入还会被当成差异(清单 A09)。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET gender = ? WHERE id = ?`, in.Gender, ex.ID); err != nil {
return internal(err)
}
}
return nil
}
@@ -456,6 +525,18 @@ type entryImportOutcome struct {
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
StatusChanged bool
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
MovedEntries []movedSameScopeEntry
}
// movedSameScopeEntry 报告一条「换单位后旧单位那条报名已被移出」。
// 姓名与新/旧单位都要带上:现场要核对的正是「这个人是不是真的换了单位」。
type movedSameScopeEntry struct {
Name string
OldTeamCode string
NewTeamCode string
}
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
@@ -465,10 +546,111 @@ func entryStatusRankable(status string) bool {
return status == "" || status == string(domain.EntryStatusRegistered)
}
// staleScopeEntry 是「同一个人 + 同一个小项 + 同一个组别」下、单位与本次导入不同的旧报名。
type staleScopeEntry struct {
ID int64
Name string
TeamCode string
}
// releaseStaleSameScopeEntries 在给这个人**新建**报名之前,处置同一个小项同一个组别里
// 单位与本次不同的旧报名。
//
// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:keep-new 把人员
// 改到新单位后,旧单位那条报名会原样留着 —— 同一个人在同一个组别出现两条,成绩录入页看得见
// 两条,现场各录一次成绩就是同一人同一组别两条成绩、重复计分(清单 A10);
// docs/报名名单导入规范.md 也承诺同一个项目的报名「不会产生重复报名」。
//
// 处置只有两种,都不许凭空销毁数据:
// - 旧报名没有任何成绩痕迹(results / score_ledger / record_breaks 全空)→ 按批量移出的口径
// 物理移出。它只是一条过期的名单行,删掉不丢任何数据;新单位这条紧接着新建,
// 调用方拿到 Created 就会重算这一组(与 recomputeImportedScopes 同一口径,
// 团体平均口径的分母也跟着这一增一减重算)。
// - 旧报名已经留下成绩痕迹 → 一行不动并拦下本行:删掉等于抹掉已经产生的名次与积分,
// 那属于「成绩 / 榜单」的处置,必须由用户到成绩录入页用批量移出显式做
// (removeEntriesTx 会连带清流水并重算),导入不许替他做这个决定。
//
// 返回被移出的旧报名(可能为空):调用方要拿它给操作员一条 notices 提示(清单 A10)。
func (s *server) releaseStaleSameScopeEntries(ctx context.Context, in entryDTO) ([]staleScopeEntry, error) {
// 团体行(athlete_id 为空)不受影响:同一个组别里两个单位各报一支队伍本来就是正常的。
if in.AthleteID == nil {
return nil, nil
}
rows, err := s.ev.QueryContext(ctx,
`SELECT e.id, a.name, t.code FROM entries e
JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? AND e.category_id = ? AND e.athlete_id = ? AND e.team_id <> ?
ORDER BY e.id`, in.EventID, in.CategoryID, *in.AthleteID, in.TeamID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
stale := make([]staleScopeEntry, 0, 1)
for rows.Next() {
var item staleScopeEntry
if err := rows.Scan(&item.ID, &item.Name, &item.TeamCode); err != nil {
return nil, internal(err)
}
stale = append(stale, item)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
for _, item := range stale {
scored, err := s.entryHasResultTraces(ctx, item.ID)
if err != nil {
return nil, err
}
if scored {
return nil, staleScoredEntryError(item)
}
}
for _, item := range stale {
// 上面已确认这条旧报名没有任何成绩痕迹,所以只删报名行本身:批量移出(removeEntriesTx)
// 还要连带清流水 / 成绩 / 破纪录,是因为那边允许移出已有成绩的报名,这里没有可清的。
if _, err := s.ev.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, item.ID); err != nil {
// 并发下刚有人给这条旧报名录了成绩:results 对 entries 没有 ON DELETE,外键会挡住删除。
// 那就按「已有成绩」处理并拦下本行,绝不强行删掉别人的成绩痕迹。
if isForeignKeyViolation(err) {
return nil, staleScoredEntryError(item)
}
return nil, internal(err)
}
}
return stale, nil
}
// entryHasResultTraces 判断一条报名是否已经留下成绩痕迹:录过成绩(草稿也算)、
// 有过流水、或登记过破纪录,任意一样成立就不再是「可以随手移出的过期名单行」。
func (s *server) entryHasResultTraces(ctx context.Context, entryID int64) (bool, error) {
var traces int
if err := s.ev.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM results WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM score_ledger WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM record_breaks WHERE entry_id = ?)`,
entryID, entryID, entryID).Scan(&traces); err != nil {
return false, internal(err)
}
return traces != 0, nil
}
// staleScoredEntryError 是「旧报名已有成绩」时的行级提示:不能只说不能做,必须给出下一步。
// 返回纯 error 而不是 apiError:调用方(handlers_config.go 的导入主循环)对 upsert 的失败
// 用 err.Error() 原样回显,包成 apiError 会多出「CONFLICT:」前缀,现场看不懂。
func staleScoredEntryError(item staleScopeEntry) error {
return fmt.Errorf("%s 在本组别还有一条单位 %s 的报名(已录过成绩),换单位后同一个人在同一组别不能留两条;请先到成绩录入页把单位 %s 那条移出,再重新上传这一行",
item.Name, item.TeamCode, item.TeamCode)
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
//
// 唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:新建之前必须先处置
// 同一个小项同一个组别里另一单位的那条旧报名(见 releaseStaleSameScopeEntries)。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) {
var (
existing int64
@@ -488,6 +670,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
status := orDefault(in.Status, "registered")
switch {
case errors.Is(err, sql.ErrNoRows):
moved, err := s.releaseStaleSameScopeEntries(ctx, in)
if err != nil {
return entryImportOutcome{}, err
}
if _, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -495,7 +681,21 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, nil
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
if len(moved) > 0 {
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
// 平时的导入不为它付代价。
newCode, err := s.teamCodeByID(ctx, in.TeamID)
if err != nil {
return entryImportOutcome{}, err
}
for _, item := range moved {
outcome.MovedEntries = append(outcome.MovedEntries, movedSameScopeEntry{
Name: item.Name, OldTeamCode: item.TeamCode, NewTeamCode: newCode,
})
}
}
return outcome, nil
case err != nil:
return entryImportOutcome{}, err
default:
+405
View File
@@ -0,0 +1,405 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件覆盖报名导入里三条联动口径(清单 2026-09-11 的 A09 / A10 / A13):
// - A09 性别:库中为空 = 待回填(不算冲突),表里没填 = 不参与比较,库中 ↔ 表里都是男/女且不同才是真冲突;
// - A10 换单位:同一个人在同一个小项同一个组别只留一条报名,旧报名已有成绩时不许悄悄删;
// - A13 换单位(没号码布):新建的第二份档案必须在 sameNameNewPersons 里报告出来。
//
// 断言全部落到库里的字段值与榜单副作用上,不停在 HTTP 200。
// importGenderRow 拼一行「M100 / 男子 / 单位 A / 张伟」,只给号码布与性别(性别可为空串)。
// 表头顺序见 importHeader:event_code, category, team_code, athlete_name, id_card, gender, age, bib, seed, status, member_names。
func importGenderRow(gender string, bib string) string {
return "M100,男子,A,张伟,," + gender + ",," + bib + ",,registered,\n"
}
// A09:库里性别为空、表里填了 男 → 命中同一人并**回填**性别,不产生冲突。
// 历史名单里性别为空是常态,按旧口径这些人会整批掉进冲突面板(而性别又从不回填),
// 现场只能逐条点「使用新数据」。
func TestImportBackfillsEmptyGenderInsteadOfConflict(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 种子人员张伟在库里性别是 男,先清空成历史数据的样子(UI 允许性别为空)。
env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA)
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("男", "A900"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("库里性别为空不该算差异: %+v", conflicts)
}
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("应命中已有人员并更新报名: %+v", result)
}
var gender sql.NullString
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if !gender.Valid || gender.String != "男" {
t.Fatalf("表里给了性别就该回填,得到 %+v", gender)
}
// 回填与报名同一条路径:库里那条报名照样在,人没有被拦在冲突里。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = ?`, ids.event, ids.athleteA); n != 1 {
t.Fatalf("应有 1 条报名,得到 %d", n)
}
// 号码布一并回填,证明走的是「命中并补空字段」那条路,而不是别的新建路径。
var bib sql.NullString
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
}
if !bib.Valid || bib.String != "A900" {
t.Fatalf("号码布应一并回填,得到 %+v", bib)
}
}
// A09:表里性别列留空 → 不参与比较:既不算冲突,也不去清库里的值(库中为空就保持为空)。
func TestImportBlankGenderColumnIsNotCompared(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA)
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("", "A900"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("表里没填性别不该算差异: %+v", conflicts)
}
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("应照常更新报名: %+v", result)
}
var gender sql.NullString
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender.Valid {
t.Fatalf("表里没填性别就不该写库,得到 %+v", gender)
}
}
// A09:真正的性别冲突(库里 男 ↔ 表里 女)仍要进冲突面板,不能被新口径放过。
func TestImportRealGenderConflictStillGoesToPanel(t *testing.T) {
env, ids, client := recorderWithSeed(t)
row := "M100,男子,A,张伟,,女,,A900,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+row, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("男 ↔ 女必须进冲突面板,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "gender" {
t.Fatalf("差异应只有 gender,得到 %+v", changed)
}
if result["updated"] != float64(0) || result["created"] != float64(0) {
t.Fatalf("冲突行不该落库: %+v", result)
}
var gender string
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender != "男" {
t.Fatalf("未决策前不该改库,得到 %s", gender)
}
// 决策「使用新数据」:库里的性别才被覆盖成表里的值。
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", importHeader+"\n"+row,
`[{"bib":"A900","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("keep-new 应更新已有报名: %+v", result)
}
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender != "女" {
t.Fatalf("keep-new 应用表里的性别覆盖,得到 %s", gender)
}
}
// A10:换单位 + keep-new 后,同一个人在同一个小项同一个组别只留一条报名。
// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开唯一索引:
// 旧单位那条报名会原样留着,现场各录一次成绩就是同一人同一组别两条成绩、重复计分。
func TestImportTeamChangeKeepsOneEntryPerScope(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
// 前置:张伟在「男子」组有一条 A 单位的报名(种子 entryA),没有成绩。
pushesBefore := standingsPushes(t, env)
body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("换单位应在新单位下建这条报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
// 数据对了还不够:旧单位的报名被移出这件事必须在导入结果里讲出来,
// 否则现场只看到「旧单位那条不见了」,分不清是移出还是丢了(清单 A10)。
notices, _ := result["notices"].([]any)
if len(notices) != 1 {
t.Fatalf("换单位应给 1 条提示,得到 %+v", notices)
}
want := "第 2 行:张伟 换了单位,原单位 A 在本组别的报名已移出,已改在单位 B 加入"
if notices[0] != want {
t.Fatalf("提示应写明姓名与新/旧单位,want %q got %q", want, notices[0])
}
// 旧单位那条过期的报名行被移出(它没有任何成绩痕迹,删掉不丢数据)。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 0 {
t.Fatalf("旧单位的过期报名应被移出,得到 %d 条", n)
}
// 同一人在同一组别只剩新单位那一条。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.athleteA); n != 1 {
t.Fatalf("同一人同一组别只应留一条报名,得到 %d 条", n)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM entries WHERE event_id = ? AND athlete_id = ?`,
ids.event, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询留下的报名: %v", err)
}
if teamID != ids.teamB {
t.Fatalf("留下的那条应属于新单位,得到 %d", teamID)
}
// 别人的报名不受影响。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 {
t.Fatalf("别的报名不该被牵连,得到 %d 条", n)
}
// 名单一增一减动了这一组的参赛成员 → 必须重算并推榜(与 recomputeImportedScopes 同一口径)。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("换单位应重算这一组并推 1 次榜单,得到 %d", got)
}
// 已经在 B 单位了,再传同一份表:只是就地更新,没有谁被移出,不该再冒这条提示。
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, env2.Error)
}
again := decodeData[map[string]any](t, env2)
if again["updated"] != float64(1) || again["created"] != float64(0) {
t.Fatalf("重传应就地更新: %+v", again)
}
if notices, _ := again["notices"].([]any); len(notices) != 0 {
t.Fatalf("没有报名被移出就不该有提示,得到 %+v", notices)
}
}
// A10:旧单位那条报名已经录过成绩 → 一行不动并拦下本行。
// 删掉它等于抹掉已经产生的名次与积分,那是「成绩 / 榜单」的处置,必须由用户用批量移出显式做。
func TestImportTeamChangeWithScoredOldEntryIsBlocked(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
publishResults(t, env, client, ids.event, "import-team-change",
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"},
)
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("前置条件:张伟应是第 1 名 10 分,得到 %v", got)
}
pushesBefore := standingsPushes(t, env)
body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(0) || result["updated"] != float64(0) || result["skipped"] != float64(1) {
t.Fatalf("这一行应被拦下(不建第二条报名),得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("应给出一条行级提示,得到 %+v", errs)
}
line, _ := errs[0].(string)
if !strings.Contains(line, "移出") || !strings.Contains(line, "第 2 行") {
t.Fatalf("提示要带行号并说清下一步(先把旧报名移出),得到 %q", line)
}
// 旧报名与它的成绩、流水、名次、积分一个都没动。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
t.Fatalf("已有成绩的旧报名不该被删,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, ids.entryA); n != 1 {
t.Fatalf("旧报名的已发布成绩应原样保留,得到 %d 条", n)
}
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("旧报名的流水应原样保留,得到 %v", got)
}
if got := rankOfEntry(t, env, ids.entryA); got != 1 {
t.Fatalf("旧报名的名次应原样保留,得到 %d", got)
}
// 新单位下没有多出第二条报名。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND team_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.teamB, ids.athleteA); n != 0 {
t.Fatalf("被拦下的行不该落库,得到 %d 条", n)
}
// 本行没有落库,就没有「谁还算数」的变化:不重算、不推榜。
if got := standingsPushes(t, env) - pushesBefore; got != 0 {
t.Fatalf("本行未落库不该推榜,得到 %d", got)
}
// 现场怎么补救:先在成绩录入页把旧报名移出(会连带清成绩与流水并重算),再重新上传这一行。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryA}, "reason": "换单位,移出旧报名",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量移出应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("移出后重新导入应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("移出旧报名后应能建上新单位这条: %+v", result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.athleteA); n != 1 {
t.Fatalf("补救后同一人同一组别应只有一条报名,得到 %d 条", n)
}
}
// A13:没号码布的人换了单位 → 这个单位下查不到同名的,会新建第二份档案,
// 必须把「别的单位下有个同名档案」列进 sameNameNewPersons,不能悄悄多一个人。
func TestImportTeamChangeReportsSameNameElsewhere(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 种子张伟在 A 单位,既没有号码布也没有证件号;表里换到 B 单位并带上他的证件号与号码布。
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,B900,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("新单位下应新建报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("这不是冲突,是同名疑似重复: %+v", conflicts)
}
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("换单位新建的档案必须报告出来,得到 %+v", reported)
}
item := reported[0].(map[string]any)
want := map[string]any{
"name": "张伟", "teamCode": "B", "reason": "team", "existingTeamCode": "A",
"newIdCard": "110101199001011234", "existingIdCard": "",
"newBib": "B900", "existingBib": "",
}
for key, value := range want {
if item[key] != value {
t.Fatalf("报告字段 %s 应为 %q,得到 %+v", key, value, item)
}
}
// 两份档案都还在(新建是既有口径,这一条修的是「不许无声」),老档案一点没被动。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE name = '张伟'`); n != 2 {
t.Fatalf("应有 2 份同名档案,得到 %d 份", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND team_id = ? AND id_card IS NULL AND bib IS NULL`,
ids.athleteA, ids.teamA); n != 1 {
t.Fatalf("A 单位那份老档案不该被改,得到 %d 份", n)
}
}
// A13 的反例保护:同单位、同名、身份字段对不上 → 报告的原因是 identity,不能被跨单位这条口径顶掉。
func TestImportSameTeamSameNameStillReportsIdentityReason(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("同名不同号码布应报告 1 条,得到 %+v", reported)
}
item := reported[0].(map[string]any)
if item["reason"] != "identity" || item["existingTeamCode"] != "A" {
t.Fatalf("同单位这条应报 identity 并带上库中单位,得到 %+v", item)
}
}
// A09 的现场形态:一整份没有性别列的旧名单先导一遍,第二天补上性别再传一遍 ——
// 第二遍必须零冲突、全员回填,且一个人都不会变成两份报名。
func TestImportRosterWithoutGenderThenBackfillsAtScale(t *testing.T) {
env, ids, client := recorderWithSeed(t)
const rows = 60
build := func(gender string) string {
body := importHeader + "\n"
for i := 0; i < rows; i++ {
body += fmt.Sprintf("M100,男子,A,选手%02d,11010119900101%04d,%s,,B%03d,,registered,\n",
i, i+1, gender, i+1)
}
return body
}
status, env2 := env.postImport(t, client, "/api/entries/import", build(""), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(rows) || result["skipped"] != float64(0) {
t.Fatalf("第一遍应新建 %d 条报名: %+v", rows, result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("第一遍不该有冲突: %+v", conflicts)
}
// 第一遍的性别列整列没填 → 新档案里就是空性别(历史数据的常态)。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender IS NULL`, ids.teamA); n != rows {
t.Fatalf("应有 %d 人性别为空,得到 %d", rows, n)
}
// 第二遍补上性别:库里性别为空 = 待回填,一个人都不该掉进冲突面板。
status, env2 = env.postImport(t, client, "/api/entries/import", build("男"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("第二遍导入应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("库里性别为空不该算差异,第二遍不该有冲突: %+v", conflicts)
}
if result["updated"] != float64(rows) || result["created"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("第二遍应就地更新 %d 条: %+v", rows, result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender = '男'`, ids.teamA); n != rows+1 {
t.Fatalf("应有 %d 人回填成 男,得到 %d", rows+1, n)
}
// 重传同一份表不会把名单变成两份:加了换单位去重后,同一个人在同一组别始终只留一条报名。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, ids.event); n != rows+2 {
t.Fatalf("报名总数应是 %d(%d 行 + 种子两人的报名),得到 %d", rows+2, rows, n)
}
}
+262
View File
@@ -490,3 +490,265 @@ func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) {
}
}
}
// assertCodeNameRequired 钉住「必填」的三态:传空串、传纯空格、整段漏传,都必须是
// 400 而不是「200 + 把库里的编码清掉」。历史伤疤:漏传被当成清空,编码是导入匹配的键,
// 清掉之后导入只会报「单位不存在」,而现场当场看不出是刚才那次编辑干的。
func assertCodeNameRequired(t *testing.T, env *testEnv, client *http.Client, what, path, table string, id int64, base map[string]any) {
t.Helper()
for _, bad := range []struct {
label string
code string
name string
}{
{"空串", "", ""},
{"纯空格", " ", " "},
{"漏传", "", ""}, // code/name 两个键都不出现在请求体里
} {
body := map[string]any{}
for k, v := range base {
body[k] = v
}
if bad.label != "漏传" {
body["code"], body["name"] = bad.code, bad.name
}
status, resp := env.do(client, http.MethodPut, path, body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s code/name %s 应 400 VALIDATION_FAILED,得到 %d %+v", what, bad.label, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("%s code/name %s 的 400 文案应点明必填,得到 %q", what, bad.label, resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM `+table+` WHERE id = ?`, id).Scan(&code); err != nil {
t.Fatalf("读%s编码: %v", what, err)
}
if strings.TrimSpace(code) == "" {
t.Fatalf("%s 被拒的请求不该改库中编码,得到 %q", what, code)
}
}
// PUT 单位 / 大项 / 小项时,code 与 name 是必填:空串、纯空格、漏传一律 400,库里的编码不动。
// 改前这三类请求都是 200,落库 code=""(或 " ")——小项与单位的编码正是导入按
// team_code / event_code 匹配的键,清空之后导入永远报「不存在」。
func TestUpdateRequiresCodeAndName(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
assertCodeNameRequired(t, env, client, "单位", fmt.Sprintf("/api/teams/%d", ids.teamA), "teams", ids.teamA, nil)
assertCodeNameRequired(t, env, client, "大项", fmt.Sprintf("/api/sports/%d", ids.sport), "sports", ids.sport, nil)
assertCodeNameRequired(t, env, client, "小项", fmt.Sprintf("/api/events/%d", ids.event), "events", ids.event,
map[string]any{"sportId": ids.sport})
// 对照:合法的更新照常 200,且首尾空白被裁掉后再落库(编码「 A2 」与「A2」从此不可能并存)。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/teams/%d", ids.teamA), map[string]any{
"code": " A2 ", "name": " 第一分公司 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("带空白的合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "A2" || got["name"] != "第一分公司" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
// 新建侧同口径:POST 带空白的编码也按裁过的值落库。
status, resp = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": " WS ", "name": " 带空格 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "WS" || got["name"] != "带空格" {
t.Fatalf("新建应落裁过空白的值,得到 %+v", got)
}
}
// A01:PUT /api/scoring-profiles/{id} 空 code/name 必须是 400。
// 改前它是 200:recorder 一按保存就能把模板的 code 清空,而 rules/validate.go 仍要求
// code 非空 —— admin 的 GET /api/rules/export 与 project.zip 从此永久 500,且没有任何提示。
func TestUpdateProfileCodeRequiredKeepsRulesExportUsable(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
rec := recorderClient(t, env)
rulesExport := func() int {
t.Helper()
resp, err := admin.Get(env.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("取规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("改前规则包导出应 200,得到 %d", got)
}
for _, body := range []map[string]any{
{"code": "", "name": ""},
{"code": " ", "name": " "},
{"tieMode": "skip"}, // 漏传 code / name
} {
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%v 应 400 VALIDATION_FAILED,得到 %d %+v", body, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("400 文案应点明必填,得到 %q", resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM scoring_profiles WHERE id = 1`).Scan(&code); err != nil {
t.Fatalf("读模板编码: %v", err)
}
if code != "individual_top6" {
t.Fatalf("被拒的更新不该改库中编码,得到 %q", code)
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("被拒的更新之后规则包导出仍应 200,得到 %d", got)
}
// 对照:合法的更新照常 200,落库的是裁掉空白后的值。
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": " P1 ", "name": " 个人项目前六名 ",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "P1" || got["name"] != "个人项目前六名" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
}
// profileRulesView 取模板的名次表,用来断言「被拒的更新没有把名次删空」。
type profileRulesView struct {
Rules []struct {
Rank int `json:"rank"`
} `json:"rules"`
}
func profileRuleRanks(t *testing.T, env *testEnv, client *http.Client, id int64) []int {
t.Helper()
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", id), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读模板应 200,得到 %d %+v", status, resp.Error)
}
view := decodeData[profileRulesView](t, resp)
ranks := make([]int, 0, len(view.Rules))
for _, r := range view.Rules {
ranks = append(ranks, r.Rank)
}
return ranks
}
// rules[] 里两行同 rank 撞 scoring_profile_rules 主键 (profile_id, rank):
// 改前是 500 + 英文约束原文,现场只看到「服务端异常」,无从知道是哪一格填重了。
func TestProfileRulesDuplicateRankRejected(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "DUP-RANK", "name": "重复名次",
"rules": []map[string]any{{"rank": 1, "points": 10}, {"rank": 1, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "1") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'DUP-RANK'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 更新路径同口径;replaceProfileRules 是「先 DELETE 再 INSERT」,被拒时整个事务必须回滚,
// 否则模板会被存成「名次表空了」这种更坏的状态。
before := profileRuleRanks(t, env, client, 1)
if len(before) == 0 {
t.Fatalf("种子模板应自带名次表")
}
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名",
"rules": []map[string]any{{"rank": 2, "points": 9}, {"rank": 2, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "2") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if !strings.Contains(string(resp.Error.Detail), `"rank":2`) {
t.Fatalf("400 的 detail 应带上重复的那一行,得到 %s", resp.Error.Detail)
}
after := profileRuleRanks(t, env, client, 1)
if len(after) != len(before) {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
}
}
// defaultProfileId 指向不存在的模板:改前撞 scoring_profiles 的外键报 500「服务端异常」,
// 同类的 events.sport_id / channels.profile_id 都是 400。两条写入路径都要一致。
func TestSportDefaultProfileMustExist(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建指向不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "defaultProfileId") {
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM sports WHERE code = 'SWIM'`); n != 0 {
t.Fatalf("被拒的大项不该落库,得到 %d 行", n)
}
status, resp = env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 1, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建大项应 200,得到 %d %+v", status, resp.Error)
}
sportID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新到不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
var profileID sql.NullInt64
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 1 {
t.Fatalf("被拒的更新不该改默认模板,得到 %+v", profileID)
}
// 对照:指向真实模板照常可写。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 2, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("指向真实模板应 200,得到 %d %+v", status, resp.Error)
}
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 2 {
t.Fatalf("合法更新应落库 2,得到 %+v", profileID)
}
}
@@ -0,0 +1,74 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 8 MiB 上限必须落在最通用的解码路径上:三十多个写接口走的都是 decodeJSON,
// 此前只有 decodeJSONPresence(建人员 / 单位那几个)卡过大小,普通 JSON 写接口没有上限。
func TestDecodeJSONRejectsOversizedBody(t *testing.T) {
body := `{"name":"` + strings.Repeat("a", maxJSONBodyBytes) + `"}`
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(body))
var dst map[string]any
err := decodeJSON(req, &dst)
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("超限应返回业务错误,得到 %v", err)
}
if api.status != http.StatusRequestEntityTooLarge {
t.Fatalf("超限应 413(而不是笼统 400「不是合法 JSON」),得到 %d %s", api.status, api.message)
}
if api.message != "请求体超过上限" {
t.Fatalf("超限文案应说清是请求体太大,得到 %q", api.message)
}
}
// 上限不能顺手把正当请求拦掉:普通写请求照常解析。
func TestDecodeJSONAcceptsNormalBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings",
strings.NewReader(`{"event":{"name":"秋季运动会"}}`))
var dst struct {
Event struct {
Name string `json:"name"`
} `json:"event"`
}
if err := decodeJSON(req, &dst); err != nil {
t.Fatalf("正常请求体应解析成功: %v", err)
}
if dst.Event.Name != "秋季运动会" {
t.Fatalf("解析结果不对: %+v", dst)
}
}
// 非法 JSON 仍然是 400「请求体不是合法 JSON」,不要被超限分支吞掉。
func TestDecodeJSONStillReportsBadJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(`{"event":`))
var dst map[string]any
err := decodeJSON(req, &dst)
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("非法 JSON 应返回业务错误,得到 %v", err)
}
if api.status != http.StatusBadRequest || api.message != "请求体不是合法 JSON" {
t.Fatalf("非法 JSON 应 400「请求体不是合法 JSON」,得到 %d %s", api.status, api.message)
}
}
// 空体仍要报错(「没传参数」不是合法写操作)——加了上限不能改变这条既有口径。
func TestDecodeJSONStillRejectsEmptyBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(""))
var dst map[string]any
if err := decodeJSON(req, &dst); err == nil {
t.Fatal("空请求体应报错")
}
}
+12 -1
View File
@@ -195,9 +195,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
//
// 与 decodeJSONPresence 用同一把尺卡住请求体大小(maxJSONBodyBytes):
// 走这条更通用的路径的写接口有三十多个,此前一个上限都没有,
// 一次请求就能把几百 MB 读进内存(单进程服务,内存放大即整站不可用)。
func decodeJSON(r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
// 超限要报 413 而不是 400「不是合法 JSON」:前端据此提示「请求体过大」,
// 而不是让现场去查一个其实没写错的 JSON。
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return tooLarge("请求体超过上限")
}
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
@@ -205,7 +216,7 @@ func decodeJSON(r *http.Request, dst any) error {
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
// 配合 decodeJSONPresence 的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
const maxJSONBodyBytes = 8 << 20
// keySet 是「请求体里真的出现过哪些键」。
+96 -9
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
@@ -583,6 +584,10 @@ func preferComputedRank(a, b domain.RankedResult) bool {
// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft:
// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行,
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
//
// 落在这一档里的还有「填了文本却没有成绩值」的草稿(客户端没算出 resultValue):
// 同样发不出去,但外表上像是录过了,所以发布时要单独计数并写进修订(countTextOnlyDrafts),
// 不能让它静默消失。
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
@@ -675,6 +680,51 @@ func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bo
return published == total, nil
}
// eventFullyPublished 判断整小项是否「所有该出成绩的报名项都已发布」。
//
// 判据与结算同源(scopeStats):退赛 / 缺赛 / 失格的报名项不进分母,因为它们本来就不等成绩。
// 必须要求 total > 0——一条报名项都没有的小项没有任何成绩可发,标成「已发布」是假信息。
func eventFullyPublished(ctx context.Context, q queryer, eventID int64) (bool, error) {
total, published, err := scopeStats(ctx, q, eventID, 0)
if err != nil {
return false, err
}
return total > 0 && published == total, nil
}
// countTextOnlyDrafts 数出「这次发布带不上、看起来却已经录过」的草稿:
// 有成绩文本,却没有成绩值、没有手工名次、状态也还是 ok。
//
// 这批行按 publishableResultCond 永远发不出去(缺的正是那个比较值),而发布接口照回 200:
// 客户端只送 resultText 时成绩就这样静默漏掉。纯空行(连文本都没有)不算——
// 分批比赛里「还没跑」是正常状态,一起数进去只会淹没真正的问题。
func countTextOnlyDrafts(ctx context.Context, q queryer, eventID, categoryID int64) (int, error) {
query := `SELECT COUNT(*) FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ? AND r.state = 'draft'
AND r.result_value IS NULL AND r.rank IS NULL AND r.status = 'ok'
AND TRIM(COALESCE(r.result_text, '')) <> ''`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
var n int
if err := q.QueryRowContext(ctx, query, args...).Scan(&n); err != nil {
return 0, internal(err)
}
return n, nil
}
// skipNote 把「跳过多少条只填了文本的草稿」写成给现场看的一句话,没有跳过时为空串
// (空串不落显眼文案,变更历史里也不会多出一行无用说明)。
func skipNote(skipped int) string {
if skipped == 0 {
return ""
}
return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped)
}
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
var raw string
@@ -723,6 +773,18 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil {
return nil, internal(err)
}
// 没被带上的「只有成绩文本」的草稿要报出来:它们按 publishableResultCond 永远发不出去
// (既没有成绩值、也没有手工名次、状态还是 ok),而接口照样回 200。
// 客户端只送 resultText 时成绩就这样静默漏掉,现场几乎发现不了;数出来写进本次修订
// (变更历史会显示),并留一条服务端日志。纯空行不算——分批比赛里「还没跑」是正常状态。
skipped, err := countTextOnlyDrafts(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
if skipped > 0 {
s.logger.Warn("发布跳过了只填成绩文本的草稿",
"eventId", in.EventID, "categoryId", catID, "count", skipped)
}
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
@@ -752,7 +814,20 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
return nil, err
}
}
if catID == 0 {
// 小项进度(events.status)里的 published 只有一种含义:整项都发完了。
// 整项发布(catID == 0)由调用方明确声明,照旧直接写;按组别发布则要先确认整项所有
// 该出成绩的报名项都已发布——录入页永远带 categoryId,少了这一步,「一组一组发完」的
// 小项在界面上会一直停在「待录入」,侧栏与仪表盘的已发布计数也永远漏掉它。
// 还没发完时一个字符都不写:一组发布 ≠ 整项已发布,把状态写成 published 只会制造
// 「已发布」的假象(大屏 / 导出虽然不读这一列,人读)。
done := catID == 0
if !done {
done, err = eventFullyPublished(ctx, tx, in.EventID)
if err != nil {
return nil, err
}
}
if done {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
@@ -762,7 +837,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
@@ -834,10 +909,16 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
// 小项进度与发布对称地退回:整项撤销到 finished(与既有行为一致);
// 按组别撤销只在整项此前被标成 published 时降级——这次撤销让整项不再是「全部已发布」,
// 状态不跟着退,界面就会写着「已发布」而其中一组其实已经撤回。
// pending / ongoing 是人工设的进度,这里不覆盖。
revert := `UPDATE events SET status = 'finished' WHERE id = ?`
if catID > 0 {
revert += ` AND status = 'published'`
}
if _, err := tx.ExecContext(ctx, revert, in.EventID); err != nil {
return nil, internal(err)
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
@@ -1071,15 +1152,21 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin
if err != nil {
return internal(err)
}
// 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。
// 父节点 = 该小项当前的 MAX(id),即上一条版本:一个小项串成**一条链**。
// 撤销后再发布也是接在链尾(新行 id 最大,就是上一条),**不会从撤销点分出分支**——
// result_revisions 实际不是 DAG(P2-C06)。每个小项的第一条版本 parent_id 为 NULL。
var parentID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil {
return internal(err)
}
// 这条 INSERT 不再写 ledger_ids:该列从来没有读取侧(变更历史只读 snapshot / action /
// message),前端却按它渲染「积分流水 N 条」,于是每次发布都在变更历史里显示 0 条——
// 一句对用户可见的错信息。收口方向是连同前端那一栏一起删掉,写入侧先与它脱钩:
// 列上有 NOT NULL DEFAULT '[]',不写照样满足约束,删列迁移落地后这条语句也依然成立。
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, actor_id, message)
VALUES (?, ?, ?, ?, ?, ?)`,
parentID, eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
+155
View File
@@ -3,6 +3,7 @@ package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
@@ -171,3 +172,157 @@ func TestVoidResultRewritesRemainingRanks(t *testing.T) {
}
}
}
// eventStatus 读小项进度(events.status)。
func eventStatus(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
var s string
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, eventID).Scan(&s); err != nil {
t.Fatalf("读取小项状态: %v", err)
}
return s
}
// A07:result_revisions.ledger_ids 没有任何读取侧(变更历史只读 snapshot / action / message),
// 写入侧与它脱钩之后,列被迁移删掉也必须不影响发布记录修订——否则删列一上线就发不出成绩。
func TestPublishRevisionWriteDoesNotNeedLedgerIdsColumn(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.exec(t, `ALTER TABLE result_revisions DROP COLUMN ledger_ids`)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("删掉 ledger_ids 列后发布仍应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM result_revisions WHERE event_id = ?`, 1, ids.event)
}
// A14:只有成绩文本的草稿按 publishableResultCond 永远发不出去,而接口照回 200。
// 发布必须把「跳过几条」写进本次修订(变更历史直接显示 message),静默漏成绩才算被打破。
func TestPublishReportsTextOnlyDrafts(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第三条报名项连成绩都没录:它是正常的「还没跑」,不能被算进跳过的条数里。
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athlete)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
// 客户端只送了文本、没算出成绩值(resultValue 缺失)。
{"entryId": ids.entryB, "resultText": "11.92", "unit": "s", "status": "ok"},
{"entryId": entryC, "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
// 只有文本的那条留在 draft,有值的那条上屏。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, 1, ids.entryA)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'draft'`, 1, ids.entryB)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读变更列表应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("发布后应有 1 条修订,得到 %d", len(items))
}
message, _ := items[0].(map[string]any)["message"].(string)
if !strings.Contains(message, "跳过 1 条") {
t.Fatalf("修订应写明跳过 1 条无值草稿(空行不算),得到 %q", message)
}
}
// A15:录入页永远带 categoryId,按组别发布是常规路径。小项进度(events.status)
// 不能在只发完一组时就写 published,也不能在整项都发完后还停在原状态——
// 后者会让界面一直显示「待录入」、侧栏与仪表盘的已发布计数永远漏掉这一项。
func TestCategoryPublishAdvancesEventStatusWhenDone(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第二个组别 + 两条报名项:整项共有 4 条该出成绩的报名项。
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
athD := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵敏', '女', ?, 1)`, ids.teamB)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamA, athC)
entryD := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, athD)
save := func(categoryID int64, rows []map[string]any) {
t.Helper()
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": categoryID, "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 存草稿应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
publish := func(categoryID int64) {
t.Helper()
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": categoryID,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 发布应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
save(ids.category, []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
})
save(cat2, []map[string]any{
{"entryId": entryC, "resultText": "12.30", "resultValue": 12.30, "unit": "s", "status": "ok"},
{"entryId": entryD, "resultText": "12.44", "resultValue": 12.44, "unit": "s", "status": "ok"},
})
publish(ids.category)
if got := eventStatus(t, env, ids.event); got != "ongoing" {
t.Fatalf("只发完一个组不得把小项标成已发布,得到 %q", got)
}
publish(cat2)
if got := eventStatus(t, env, ids.event); got != "published" {
t.Fatalf("整项所有组都发完后小项应标成 published,得到 %q", got)
}
if status, e := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("撤销一个组应 200,得到 %d %+v", status, e.Error)
}
if got := eventStatus(t, env, ids.event); got != "finished" {
t.Fatalf("撤回一个组后小项不该再是已发布,得到 %q", got)
}
}
@@ -0,0 +1,210 @@
package httpapi_test
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// countUserSessions 数某账号在库里的会话行。
func countUserSessions(t *testing.T, env *testEnv, userID int64) int {
t.Helper()
var n int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil {
t.Fatalf("统计会话: %v", err)
}
return n
}
// meRole 走 /api/auth/me 读当前身份:会话被撤之后这里会退回 guest。
func meRole(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读 /api/auth/me 应 200,得到 %d %+v", status, body.Error)
}
return decodeData[map[string]any](t, body)["role"].(string)
}
// 会话失效的三条管理动作(重置密码 / 停用 / 自己改密)必须**当场**生效:
// 旧 Cookie 下一次请求就该被当成访客,库里那行会话也该没了。
// 在此之前这些承诺只有 auth 包内的函数级断言,没有一条走 HTTP 的端到端用例——
// 管理端 handler 漏调撤会话(或撤了却忘了清 Cookie)不会有任何东西变红。
func TestAdminActionsInvalidateSessionsImmediately(t *testing.T) {
env := newTestEnv(t)
admin := adminClient(t, env)
uid := env.createUser("recorder", "recorder-pass", "recorder")
worker, _ := env.client()
env.login(worker, "recorder", "recorder-pass")
if got := meRole(t, env, worker); got != "recorder" {
t.Fatalf("登录后身份应为 recorder,得到 %q", got)
}
// ① 管理员重置密码:旧会话立即失效(旧 Cookie 直接当访客用),库里的行也没了。
status, body := env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", uid),
map[string]any{"password": "new-recorder-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("重置密码后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, worker); got != "guest" {
t.Fatalf("重置密码后旧会话应退回 guest,得到 %q", got)
}
status, body = env.do(worker, http.MethodPost, "/api/teams",
map[string]any{"code": "X1", "name": "旧会话不该还能写"}, true)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("重置密码后旧会话的写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
// 旧密码登不进去,新密码可以:证明失效的是会话,不是账号。
fresh, _ := env.client()
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("重置密码后旧密码不该还能登录,得到 %d", status)
}
env.login(fresh, "recorder", "new-recorder-pass")
if got := meRole(t, env, fresh); got != "recorder" {
t.Fatalf("新密码登录后身份应为 recorder,得到 %q", got)
}
// ② 停用账号:刚建好的这条新会话也必须当场失效。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", uid),
map[string]any{"active": false}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("停用账号应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("停用后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, fresh); got != "guest" {
t.Fatalf("停用后旧会话应退回 guest,得到 %q", got)
}
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "new-recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("停用后不该还能登录,得到 %d", status)
}
}
// 自己改密:本人的会话也要当场作废,并明确告诉前端「重新登录」。
// 只清 Cookie 不撤会话(或反过来)都算没做到——前者旧 Cookie 还能用,后者页面看着没退。
func TestChangeOwnPasswordInvalidatesOwnSession(t *testing.T) {
env := newTestEnv(t)
uid := env.createUser("recorder", "recorder-pass", "recorder")
client, jar := env.client()
env.login(client, "recorder", "recorder-pass")
// 改密前先把旧会话 token 记下来:改密之后要用它重放(模拟「Cookie 被抄走/浏览器还没收到清除指令」)。
u, err := url.Parse(env.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
oldSession := ""
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession {
oldSession = c.Value
}
}
if oldSession == "" {
t.Fatalf("登录后 Cookie 里应有 %s", auth.CookieSession)
}
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "recorder-pass", "newPassword": "brand-new-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改密应 200,得到 %d %+v", status, body.Error)
}
if relogin, _ := decodeData[map[string]any](t, body)["relogin"].(bool); !relogin {
t.Fatalf("改密回执应带 relogin=true 引导重新登录,得到 %+v", decodeData[map[string]any](t, body))
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("改密后自己的会话行应清空,库里还剩 %d 行", n)
}
// 服务端下发了删除指令:浏览器不会再带着这枚 Cookie 发请求。
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession && c.Value != "" && c.MaxAge >= 0 {
t.Fatalf("改密应答应清掉会话 Cookie,得到 %+v", c)
}
}
// 旧 Cookie 手工塞回来重放也是访客:撤会话与清 Cookie 两件事必须都做到,
// 只做后者的话,任何抓到过旧 Cookie 的地方都还能继续用这个身份。
replay, err := http.NewRequest(http.MethodGet, env.server.URL+"/api/auth/me", nil)
if err != nil {
t.Fatalf("构造重放请求: %v", err)
}
replay.AddCookie(&http.Cookie{Name: auth.CookieSession, Value: oldSession})
resp, err := (&http.Client{}).Do(replay)
if err != nil {
t.Fatalf("重放旧 Cookie: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(raw), `"role":"guest"`) {
t.Fatalf("旧 Cookie 重放应被当成访客,得到 %d %s", resp.StatusCode, raw)
}
}
// 滑动续期必须带着 Cookie 的 Max-Age 一起走,并且 Max-Age 跟的是**该会话的剩余寿命**,
// 不是当前有效期:有效期被改短之后,老会话仍按自己的到期时间下发(否则浏览器比服务端早丢 Cookie)。
func TestMeRefreshesCookieMaxAgeFromSessionExpiry(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 登录时会话到期 = 登录时刻 + 24h;把有效期改短成 1 小时(不动已存在的会话)。
if status, body := env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
t.Fatalf("把有效期改成 1 小时应 200,得到 %d %+v", status, body.Error)
}
stored := sessionExpiry(t, env, "admin")
resp, err := client.Get(env.server.URL + "/api/auth/me")
if err != nil {
t.Fatalf("请求 /auth/me: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
var session, csrf *http.Cookie
for _, c := range resp.Cookies() {
switch c.Name {
case auth.CookieSession:
session = c
case auth.CookieCSRF:
csrf = c
}
}
if session == nil || csrf == nil {
t.Fatalf("/auth/me 应重发会话与 CSRF Cookie(滑动续期要同步浏览器端的 Max-Age),得到会话 %+v / CSRF %+v",
session, csrf)
}
// 剩余寿命 = 库里那条会话的到期时间 - 现在(容许几秒误差)。
want := int(time.Until(stored).Seconds())
if diff := session.MaxAge - want; diff > 3 || diff < -3 {
t.Fatalf("会话 Cookie 的 Max-Age 应等于这条会话的剩余寿命 %d 秒,得到 %d", want, session.MaxAge)
}
// 关键对照:有效期已经改成 1 小时了,Max-Age 不能跟着它变成 3600。
if session.MaxAge < 20*60*60 {
t.Fatalf("Max-Age 跟错了对象(疑似用了当前有效期而非会话到期时间):%d,库里到期时间是 %s",
session.MaxAge, stored.Format(time.RFC3339))
}
// CSRF 不轮换:值必须与 Cookie 里原样一致,否则飞行中的写请求会集体 403。
if _, err := url.Parse(env.server.URL); err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
if csrf.MaxAge != session.MaxAge {
t.Fatalf("CSRF Cookie 应与会话 Cookie 同一期限,得到 %d / %d", csrf.MaxAge, session.MaxAge)
}
if strings.TrimSpace(csrf.Value) == "" {
t.Fatalf("重发的 CSRF Cookie 不该是空值")
}
}