Files
Scoreboard/server/internal/httpapi/api_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

356 lines
14 KiB
Go

package httpapi_test
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"testing"
)
type seedIDs struct {
teamA, teamB int64
athleteA, athB int64
sport, event int64
category, entryA int64
entryB int64
}
func (e *testEnv) seed(t *testing.T) seedIDs {
t.Helper()
var ids seedIDs
ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB)
ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
ids.event = e.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event)
var profileID int64
if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID)
ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA)
ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB)
return ids
}
func (e *testEnv) exec(t *testing.T, query string, args ...any) {
t.Helper()
if _, err := e.ev.Exec(query, args...); err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
}
// entryBoard 是 GET /api/events/{id}/entries 的响应(设计 17 §七:成绩行 + 随行下发的团体名次)。
type entryBoard struct {
Entries []map[string]any `json:"entries"`
TeamRanks []map[string]any `json:"teamRanks"`
}
// getEntryBoard 取成绩录入接口的完整读数:报名项 + 成绩 + 该小项的团体名次视图。
func getEntryBoard(t *testing.T, env *testEnv, client *http.Client, eventID int64) entryBoard {
t.Helper()
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
}
return decodeData[entryBoard](t, resp)
}
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
t.Helper()
res, err := e.ev.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}
// 未登录 = guest:赛事数据可读,系统配置一律 403。
func TestGuestReadsEventDataButNotConfig(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
client, _ := env.client()
if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK)
}
if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK)
}
for _, path := range []string{"/api/settings", "/api/audit-logs"} {
status, env2 := env.do(client, http.MethodGet, path, nil, false)
if status != http.StatusForbidden {
t.Fatalf("guest 读 %s 应 403,得到 %d", path, status)
}
if env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error)
}
}
// 未登录写操作要求先登录。
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
}
// recorder 拥有录入权限,但没有系统配置权限。
func TestRecorderForbiddenOnAdminRoutes(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"name": "改名"},
}, true)
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读审计应 403,得到 %d", status)
}
// recorder 的合法写操作仍然可用。
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 建单位应 200,得到 %d %+v", status, env2.Error)
}
}
// 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。
func TestLoginCreatesSession(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var count int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil {
t.Fatalf("查询会话: %v", err)
}
if count != 1 {
t.Fatalf("登录后应有 1 条会话,得到 %d", count)
}
status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d", status)
}
me := decodeData[map[string]any](t, env2)
if me["username"] != "recorder" || me["role"] != "recorder" {
t.Fatalf("me 返回身份不对: %+v", me)
}
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false)
me = decodeData[map[string]any](t, env2)
if status != http.StatusOK || me["role"] != "guest" {
t.Fatalf("登出后应为 guest,得到 %d %+v", status, me)
}
}
// 首次启动创建默认管理员并打印随机初始密码。
func TestEnsureDefaultAdmin(t *testing.T) {
env := newTestEnv(t)
username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context())
if err != nil {
t.Fatalf("创建默认管理员: %v", err)
}
if !created || username == "" || len(password) < 12 {
t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password))
}
client, _ := env.client()
env.login(client, username, password)
}
// 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。
func TestPublishWritesLedgerAndAudit(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
results := decodeData[[]map[string]any](t, env2)
if len(results) != 2 {
t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results))
}
for _, r := range results {
if r["state"] != "published" {
t.Fatalf("成绩应为 published: %+v", r)
}
}
// 流水:A 10 分、B 8 分。
rows, err := env.ev.Query(
`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
points := map[int64]float64{}
for rows.Next() {
var team int64
var p float64
if err := rows.Scan(&team, &p); err != nil {
t.Fatalf("扫描流水: %v", err)
}
points[team] = p
}
if points[ids.teamA] != 10 || points[ids.teamB] != 8 {
t.Fatalf("流水分值不对: %+v", points)
}
// 积分榜:A 第一。
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 {
t.Fatalf("积分榜不对: %+v", standings)
}
if standings[0]["gold"].(float64) != 1 {
t.Fatalf("A 应记 1 金: %+v", standings[0])
}
// 审计:操作人、IP、request_id、before/after 都要有。
var (
action string
auditUser sql.NullInt64
ip, requestID sql.NullString
before, after sql.NullString
)
err = env.ev.QueryRow(
`SELECT action, user_id, ip, request_id, before_json, after_json
FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`).
Scan(&action, &auditUser, &ip, &requestID, &before, &after)
if err != nil {
t.Fatalf("查询审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser)
}
if !ip.Valid || ip.String == "" {
t.Fatalf("审计应记录 IP")
}
if !requestID.Valid || requestID.String == "" {
t.Fatalf("审计应记录 request_id")
}
if !before.Valid || !after.Valid {
t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after)
}
var parsed []map[string]any
if err := json.Unmarshal([]byte(after.String), &parsed); err != nil {
t.Fatalf("审计 after_json 应为成绩数组: %v", err)
}
}
// 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400;
// 同一个 X-Request-Id 重复提交应回放首次结果。
func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"}
status, first := env.do(client, http.MethodPost, "/api/teams", body, true)
if status != http.StatusOK || !first.OK {
t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error)
}
headers := map[string]string{"X-Request-Id": "replay-key-1"}
status, second := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers)
if status != http.StatusOK {
t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status)
}
status, replay := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers)
if status != http.StatusOK || string(replay.Data) != string(second.Data) {
t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data)
}
var teams int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil {
t.Fatalf("统计单位: %v", err)
}
if teams != 2 {
t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams)
}
}
// 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。
func TestPublishIdempotentReplay(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.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
body := map[string]any{"eventId": ids.event, "requestId": "publish-same"}
_, first := env.do(client, http.MethodPost, "/api/results/publish", body, true)
_, second := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if string(first.Data) != string(second.Data) {
t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data)
}
var ledgerRows int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil {
t.Fatalf("统计流水: %v", err)
}
if ledgerRows != 2 {
t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows)
}
}