## 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 例 + 语法检查。
356 lines
14 KiB
Go
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)
|
|
}
|
|
}
|