feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项

## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

@@ -0,0 +1,233 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// breakdownItems 读某单位的积分明细行。
func breakdownItems(t *testing.T, env *testEnv, client *http.Client, teamID int64) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", teamID), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
rows := decodeData[map[string]any](t, env2)["items"].([]any)
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
t.Fatalf("明细行不是对象: %+v", row)
}
out = append(out, item)
}
return out
}
// publishTrailResults 走真实的草稿 + 发布接口发布一批成绩(每次换一个 requestId,不走幂等缓存)。
func publishTrailResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string,
rows ...map[string]any) {
t.Helper()
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID, "requestId": tag + "-draft", "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// 「发布 → 裁判发现点错、退回草稿改一遍 → 重新发布」之后,明细要能说清这条成绩被改过、
// 最后一次因为什么改、什么时候 —— 现场观众看到的分要是解释得清的。
//
// 同时钉住「重新发布会产生新流水行」这件事:修订痕迹跟着 (小项, 报名项) 这条线走,
// 换了流水 id 照样追得到。
func TestBreakdownCarriesRevisionTrailAfterRepublish(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
firstLedger := items[0]["ledgerId"]
if got := items[0]["revisionCount"]; got != float64(0) {
t.Fatalf("刚发布、没人改过的成绩 revisionCount 应为 0,得到 %v", got)
}
if items[0]["lastRevisionAt"] != nil || items[0]["lastRevisionReason"] != nil {
t.Fatalf("没改过的成绩不该有修订时间 / 原因: %+v", items[0])
}
// 裁判发现成绩录错:退回草稿(写理由)→ 改成正确值 → 重新发布。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s",
"status": "ok", "note": "成绩录入笔误"})
items = breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
item := items[0]
if item["ledgerId"] == firstLedger {
t.Fatalf("重新发布应作废旧流水、写一条新流水(旧 id %v),得到 %v", firstLedger, item["ledgerId"])
}
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("改过一次的成绩 revisionCount 应为 1,得到 %v(%+v)", got, item)
}
if got := item["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("最后一次修订原因 = %v,期望 成绩录入笔误", got)
}
if at, ok := item["lastRevisionAt"].(string); !ok || at == "" {
t.Fatalf("最后一次修订时间应下发,得到 %v", item["lastRevisionAt"])
}
if got := item["lastRevisedBy"]; got != "recorder" {
t.Fatalf("最后一次修订的操作人 = %v,期望 recorder", got)
}
// 未登录访客(大屏读的就是这个接口)拿得到「被改过、什么时候、为什么」,
// 但拿不到「是谁点错的」—— 那是裁判自己看的账,与 /api/revisions 只对 recorder 开放同一口径。
guest, _ := env.client()
items = breakdownItems(t, env, guest, ids.teamA)
if got := items[0]["revisionCount"]; got != float64(1) {
t.Fatalf("访客也该看到修订次数,得到 %v", got)
}
if got := items[0]["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("访客也该看到修订原因,得到 %v", got)
}
if got := items[0]["lastRevisedBy"]; got != nil {
t.Fatalf("访客不该拿到操作人姓名,得到 %v", got)
}
}
// 一次都没改过的成绩不能标成「已修订」——这是最要防的误报。
//
// 两次发布之间引擎会重算名次、把 state / verified_at 重写一遍,同小项别人的成绩也在变;
// 这些都不该让一条没动过的成绩挂上角标。
func TestBreakdownKeepsUnchangedResultUnrevised(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
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"})
// A 单位这条成绩一个字没改,只是把别人的成绩改了一次再发布(名次会跟着重算)。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryB).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "计时有误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryB, "resultValue": 11.60, "resultText": "11.60", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
for field, want := range map[string]any{
"revisionCount": float64(0),
"lastRevisionAt": nil,
"lastRevisionReason": nil,
"lastRevisedBy": nil,
} {
if got := items[0][field]; got != want {
t.Fatalf("没改过的成绩 %s 应为 %v,得到 %v(%+v)", field, want, got, items[0])
}
}
}
// 破纪录加分这张流水是站在成绩旁边的另一笔分:它不继承成绩的修订痕迹。
// 挂上去等于对外说「那笔加分被改过」,而现场改的是成绩 —— 那是误报。
func TestBreakdownRecordBonusDoesNotInheritTrail(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s", "status": "ok"})
// 同一报名项再挂一条破纪录加分流水(与 writeLedgerTx 产出的形态一致:带 entry_id / result_id)。
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, ?, ?, ?, ?, 'record_bonus', 1, 3, 1)`,
ids.teamA, ids.athleteA, ids.event, ids.entryA, resultID)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2(成绩 + 破纪录加分)", len(items))
}
for _, item := range items {
switch item["sourceType"] {
case "result":
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("被改过的成绩应报 1 次修订,得到 %v(%+v)", got, item)
}
case "record_bonus":
if got := item["revisionCount"]; got != nil {
t.Fatalf("破纪录加分不继承成绩的修订痕迹,得到 %v(%+v)", got, item)
}
default:
t.Fatalf("意外的来源类型: %+v", item)
}
}
}
// 聚合流水(按队内平均)与人工调整没有「一条成绩」可追:整组字段留空,不许猜一个数字出来。
func TestBreakdownAggregateAndAdjustmentHaveNoTrail(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, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, NULL, NULL, NULL, 'adjustment', 1, 5, 1)`, ids.teamA)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2", len(items))
}
for _, item := range items {
if got := item["revisionCount"]; got != nil {
t.Fatalf("追不出来源的流水 revisionCount 应为 null,得到 %v(%+v)", got, item)
}
}
}
@@ -0,0 +1,157 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 聚合口径(通道的 aggregateMode:avg 平均 / sum 总和)的 REST 契约与端到端效果。
//
// 契约:
// - 请求体写 sum 能存下、读回来还是 sum;
// - 不写该字段(旧客户端 / 老配置)落成缺省 avg,响应里不下发——老数据的响应体一个字节都不变;
// - 认不出的取值 400 拦下,而不是静默按 avg 处理(拼错的配置会静默改口径,现场看不出来)。
//
// 效果:现场切换口径后重算,单位总分(此处看流水)必须按新口径走。
func TestChannelAggregateModeSwitch(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
// 团体引体向上:A 队 2 人(10 + 1)、B 队 1 人(9)。
// 总数:A 11 > B 9;平均:A 5.5 < B 9 —— 两种口径的名次正好互换。
ev := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'TEAM_PULLUP', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ev)
athA1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('甲', '男', ?, 1)`, ids.teamA)
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('乙', '男', ?, 1)`, ids.teamA)
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA1)
entryA2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA2)
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, ids.athB)
chans := fmt.Sprintf("/api/events/%d/channels", ev)
// setMode 通过 REST 写通道配置;mode 为空串表示不带该字段(模拟旧客户端)。
setMode := func(mode string) []map[string]any {
t.Helper()
channel := map[string]any{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1}
if mode != "" {
channel["aggregateMode"] = mode
}
status, resp := env.do(client, http.MethodPut, chans, map[string]any{"channels": []map[string]any{channel}}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("写通道(aggregateMode=%q)应 200,得到 %d %+v", mode, status, resp.Error)
}
return decodeData[[]map[string]any](t, resp)
}
storedMode := func() string {
t.Helper()
var mode string
if err := env.ev.QueryRow(`SELECT aggregate_mode FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, ev).Scan(&mode); err != nil {
t.Fatalf("读取落库的聚合口径: %v", err)
}
return mode
}
listedMode := func() (string, bool) {
t.Helper()
status, resp := env.do(client, http.MethodGet, chans, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error)
}
got := decodeData[[]map[string]any](t, resp)
raw, ok := got[0]["aggregateMode"]
if !ok {
return "", false
}
return raw.(string), true
}
teamLedger := func() map[int64]float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT team_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND event_id = ? GROUP BY team_id`, ev)
if err != nil {
t.Fatalf("统计单位流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]float64)
for rows.Next() {
var teamID int64
var points float64
if err := rows.Scan(&teamID, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[teamID] = points
}
return out
}
// 1) 缺省:不写 aggregateMode,落库是 avg,响应里也不下发该字段(老数据响应体不变)。
channels := setMode("")
if _, ok := channels[0]["aggregateMode"]; ok {
t.Errorf("缺省口径(avg)不应出现在响应里,得到 %+v", channels[0])
}
if got := storedMode(); got != "avg" {
t.Fatalf("不带 aggregateMode 落库 = %q,期望 avg", got)
}
// 录成绩并发布:B 队平均 9 第一(15 分),A 队平均 5.5 第二(10 分)。
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ev,
"results": []map[string]any{
{"entryId": entryA1, "resultValue": 10, "unit": "个", "status": "ok"},
{"entryId": entryA2, "resultValue": 1, "unit": "个", "status": "ok"},
{"entryId": entryB1, "resultValue": 9, "unit": "个", "status": "ok"},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ev}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("avg 口径下单位流水 = %+v,期望 A=10 B=15", got)
}
// 2) 现场切成总和:改通道配置即触发重算,名次互换(A 11 > B 9)。
channels = setMode("sum")
if channels[0]["aggregateMode"] != "sum" {
t.Errorf("sum 口径应原样读回,得到 %+v", channels[0])
}
if got := storedMode(); got != "sum" {
t.Fatalf("sum 口径落库 = %q,期望 sum", got)
}
if got, ok := listedMode(); !ok || got != "sum" {
t.Fatalf("通道列表应带 aggregateMode=sum,得到 %q(下发=%v)", got, ok)
}
if got := teamLedger(); got[ids.teamA] != 15 || got[ids.teamB] != 10 {
t.Fatalf("sum 口径下单位流水 = %+v,期望 A=15 B=10", got)
}
// 3) 切回缺省:不带该字段保存后,口径与落到 avg 的榜都要回来。
setMode("")
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("切回 avg 后单位流水 = %+v,期望 A=10 B=15", got)
}
// 4) 认不出的取值 400,且不能把已配好的口径改坏。
status, resp = env.do(client, http.MethodPut, chans, map[string]any{
"channels": []map[string]any{
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1, "aggregateMode": "average"},
},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "aggregateMode") {
t.Fatalf("非法聚合口径应 400 并点明 aggregateMode,得到 %d %+v", status, resp.Error)
}
if got := storedMode(); got != "avg" {
t.Fatalf("被拒的请求不应改动落库口径,得到 %q", got)
}
}
+14
View File
@@ -56,6 +56,10 @@ type channelDTO struct {
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和。
// 请求体里缺省(空串)按 avg 处理,不带该字段的旧客户端保存通道时口径不变。
// 响应里只下发非缺省值(sum);缺省(avg)不下发,老数据的响应体因此一个字节都不变。
AggregateMode string `json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
CountToTeam *bool `json:"countToTeam"`
@@ -188,6 +192,16 @@ type breakdownItemDTO struct {
ResultText *string `json:"resultText"`
Points float64 `json:"points"`
CreatedAt string `json:"createdAt"`
// 修订痕迹(口径见 internal/revisions):这条成绩发布之后被改过几次、最后一次因为什么改、什么时候。
//
// 三个字段一起看:RevisionCount 为 nil 表示**追不出来**(没有版本基线),为 0 表示追出来了、
// 确实没改过;前端只在 > 0 时显示「已修订 ×N」角标。宁可为空也不误报。
// LastRevisionReason / LastRevisedBy 都可能为 nil:现场没写原因、操作人已删号时就是空的。
RevisionCount *int `json:"revisionCount"`
LastRevisionAt *string `json:"lastRevisionAt"`
LastRevisionReason *string `json:"lastRevisionReason"`
LastRevisedBy *string `json:"lastRevisedBy"`
}
type teamBreakdownDTO struct {
@@ -0,0 +1,200 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"testing"
)
// 本文件钉住工单 A-1 / A-2 两条「同一份数据发布两次结果不同」的失败场景。
// 两者的共同根因是 results.rank 一列身兼两职——它既是自动算出的个人名次的回写位,
// 又是人工名次的录入位:
//
// A-1:团体报名项(athlete_id 为空)的 results.rank 被个人通道自动回写,
// 下一次发布时又被团体手工通道的兜底读成「团体名次」,团体分凭空多出一份
// (发布 #1 得 10/8/6,发布 #2 变 25/18/14);
// A-2:[athlete,result] 与 [athlete,manual] 共存时,自动名次覆盖人工名次,
// 人工通道的输入被就地改写 → 第二次发布才算对(发布 #1 得 18 分、#2 得 20 分)。
// seedDualChannelTeamEvent 建工单 A-1 的形态:team 类型小项 + 3 条团体报名项
// (athlete_id 为空,正是《报名名单导入规范》要求的团体项目填法)+ 双通道
// [athlete/result, team/manual](自带规则包曾给 TEAM_PULLUP 配的就是这两条)。
func seedDualChannelTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64, entries []int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'EMIX', '混合配置试验', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6"))
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6"))
teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active)
VALUES ('C', '第三分公司', '三司', 2, 1)`)
for _, teamID := range []int64{ids.teamA, ids.teamB, teamC} {
entries = append(entries, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, teamID))
}
return eventID, categoryID, entries
}
// resultRank 读一条成绩的手工名次(NULL 与 0 都表示「没填」)。
func resultRank(t *testing.T, env *testEnv, entryID int64) int {
t.Helper()
var rank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
t.Fatalf("读报名项 %d 的名次: %v", entryID, err)
}
if !rank.Valid {
return 0
}
return int(rank.Int64)
}
// resultRanksDump 导出某小项每条成绩的手工名次,用于断言「自动名次有没有被写回 results.rank」。
func resultRanksDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(`SELECT entry_id, COALESCE(rank, 0) AS rank, COALESCE(result_value, 0) AS value
FROM results WHERE event_id = %d ORDER BY entry_id`, eventID))
}
// A-1:团体报名项 + 双通道,连发 1~3 次必须逐字节一致。
func TestPublishTwiceWithTeamEntryAndDualChannelIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _, entries := seedDualChannelTeamEvent(t, env, ids)
publishResults(t, env, client, eventID, "a1-p1",
map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"},
)
// 团体报名项的 results.rank 是「单位名次」的录入位(团体手工通道的兜底来源),
// 个人通道算出的自动名次不许写进去——写进去第二次发布就会被当成团体名次再发一遍分。
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 {
t.Fatalf("团体报名项的 results.rank 被自动名次回写,得到 %d 行:\n%s", n, resultRanksDump(t, env, eventID))
}
if total := activeLedgerTotal(t, env); total != 24 {
t.Fatalf("首次发布应只有个人通道的 10+8+6=24 分(团体名次未录),得到 %v", total)
}
ledgerFirst := ledgerActiveDump(t, env)
ranksFirst := channelRanksDump(t, env)
for _, tag := range []string{"a1-p2", "a1-p3"} {
publishEvent(t, env, client, eventID, tag)
if total := activeLedgerTotal(t, env); total != 24 {
t.Fatalf("%s:重复发布后合计应为 24(团体手工通道没有名次可发),得到 %v", tag, total)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got)
}
if got := channelRanksDump(t, env); got != ranksFirst {
t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got)
}
}
}
// A-2:同一 result 同时被「自动名次通道」与「人工名次通道」消费时,人工名次不被覆盖。
func TestManualAthleteRankNotOverwrittenByComputedRank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 在既有的 [athlete,result] 之外再加一条 [athlete,manual](唯一约束按 scope+basis 允许共存)。
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'manual', ?, 1)`, ids.event, profileIDByCode(t, env, "individual_top6"))
// 成绩 30 / 29(compare=asc:数值小的靠前)→ 自动名次本该是 乙1 第 1、甲1 第 2;
// 人工名次反着填(甲1 第 1、乙1 第 2),两次发布都必须按人工名次算。
publishResults(t, env, client, ids.event, "a2-p1",
map[string]any{"entryId": ids.entryA, "resultValue": 30, "resultText": "30", "unit": "次", "status": "ok", "rank": 1},
map[string]any{"entryId": ids.entryB, "resultValue": 29, "resultText": "29", "unit": "次", "status": "ok", "rank": 2},
)
if rankA, rankB := resultRank(t, env, ids.entryA), resultRank(t, env, ids.entryB); rankA != 1 || rankB != 2 {
t.Fatalf("人工名次被自动名次覆盖:甲1=%d、乙1=%d,期望 1 / 2", rankA, rankB)
}
// 自动通道 8/10 + 人工通道 10/8:A 18 分、B 18 分。
if got := teamPoints(t, env, client, ids.teamA); got != 18 {
t.Fatalf("首次发布后 A 应为 18 分(自动 8 + 人工 10),得到 %v", got)
}
ledgerFirst := ledgerActiveDump(t, env)
ranksFirst := channelRanksDump(t, env)
for _, tag := range []string{"a2-p2", "a2-p3"} {
publishEvent(t, env, client, ids.event, tag)
if rankA := resultRank(t, env, ids.entryA); rankA != 1 {
t.Fatalf("%s:人工名次又被改写,甲1=%d,期望 1", tag, rankA)
}
if got := teamPoints(t, env, client, ids.teamA); got != 18 {
t.Fatalf("%s:重复发布后 A 应仍为 18 分,得到 %v", tag, got)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got)
}
if got := channelRanksDump(t, env); got != ranksFirst {
t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got)
}
}
}
// 修 A-1 时不能把「team + basis=result」这条路的团体名次一起修断:
// 纯团体项目的团体名次(从 channel_ranks 读)必须照旧产出。
func TestTeamResultChannelStillRanksTeams(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids)
publishResults(t, env, client, eventID, "team-result",
map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
)
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result' AND rank = 1`,
eventID, categoryID); n != 1 {
t.Fatalf("纯团体项目(team + basis=result)应产出团体名次,得到 %d 行:%s", n, channelRanksDump(t, env))
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("纯团体项目 A 应得 15 分,得到 %v", got)
}
publishEvent(t, env, client, eventID, "team-result-again")
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("重复发布后 A 应仍为 15 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 1 {
t.Fatalf("纯团体项目应只有 1 条流水,得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
}
// 团队手工名次(channel_manual_ranks)与团体报名项兜底两条来源并存时,以录入的团体名次为准;
// 个人通道的自动名次不得插进团体名次的来源。
func TestTeamManualRanksSurviveComputedRankWriteback(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, entries := seedDualChannelTeamEvent(t, env, ids)
if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
publishResults(t, env, client, eventID, "a1-manual",
map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"},
)
// 个人通道 10/8/6 + 团体手工名次 15/10。
if total := activeLedgerTotal(t, env); total != 24+25 {
t.Fatalf("首次发布合计应为 49(24 个人 + 25 团体),得到 %v", total)
}
ledgerFirst := ledgerActiveDump(t, env)
publishEvent(t, env, client, eventID, "a1-manual-again")
if total := activeLedgerTotal(t, env); total != 49 {
t.Fatalf("重复发布后合计应仍为 49,得到 %v", total)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("流水应完全一致:\n第一次 %s\n本次 %s", ledgerFirst, got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10+15 {
t.Fatalf("A 应为个人 10 + 团体 15 = 25 分,得到 %v", got)
}
}
@@ -0,0 +1,142 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 本文件钉住审查 01 的 P0:删除小项必须让它的积分流水一起离榜。
//
// 现场形态是「球类那种」小项——纯团体、名次按单位直接录进 channel_manual_ranks,
// 一条报名项都没有。这种小项正是唯一能被 DELETE /api/events/{id} 删掉、却又在榜上有分的东西:
// 有报名数据的小项会被守卫拦下(见 TestDeleteEventStillBlockedByEntries)。
// seedTeamManualOnlyEvent 建一个「只有团体名次、没有报名数据」的小项:纯团体 + 一个组别 +
// 一条 scope=team/basis=manual 通道,不建任何 entries(有 entries 就删不掉了)。
func seedTeamManualOnlyEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BASKET', '篮球', 'team', 'manual', 'score', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
addTeamManualChannel(t, env, eventID)
return eventID, categoryID
}
// standingsRaw 取积分榜原始响应体(逐字节比较用,比逐字段断言更严格)。
func standingsRaw(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
}
return string(env2.Data)
}
// P0 回归:删掉「只有团体名次、没有报名数据」的小项后,它的流水必须被作废、榜单必须跟着变。
//
// 修之前:DELETE 返回 200,但流水只是被外键置空 event_id(voided 仍是 0),
// 分数永久留在榜上,此后任何按 event_id 的重算都打不到这些行(审查 01 的实测证据)。
func TestDeleteEventVoidsTeamManualLedgerAndRepushesStandings(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 基线:还没有这个小项时的榜单,删除后必须逐字节回到这里。
baseline := standingsRaw(t, env, client)
eventID, categoryID := seedTeamManualOnlyEvent(t, env, ids)
// 名次是现场直接按单位录的,没有报名项也没有成绩行;录入即重算,分当场进榜。
if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK || !env2.OK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
// 前置条件:删除前这个小项确实在榜上有分,否则这条用例证明不了任何事。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID); n != 2 {
t.Fatalf("前置条件:小项应有 2 条有效流水,得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("前置条件:A 总分应为 15,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("前置条件:B 总分应为 10,得到 %v", got)
}
if gold := teamGold(t, env, client, ids.teamA); gold != 1 {
t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold)
}
standingsBefore := standingsChangeRows(t, env)
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("删除小项应 200,得到 %d %+v", status, env2.Error)
}
// 1) 流水是作废(保留行 + 作废原因),不是没有痕迹地消失。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 0 {
t.Fatalf("删除后不该还有有效流水(孤儿会永久留在榜上),得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE voided = 1 AND void_reason = '小项删除,本小项积分流水一并作废'`); n != 2 {
t.Fatalf("应留下 2 条带作废原因的流水作为审计痕迹,得到 %d 条", n)
}
// 作废行此刻的 event_id 已被外键置空:能作废成功本身就说明 UPDATE 排在 DELETE FROM events 之前,
// 顺序反了这里就是 0 条(上面两条断言会先失败)。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id IS NULL AND voided = 1`); n != 2 {
t.Fatalf("作废行应保留下来(event_id 置空),得到 %d 条", n)
}
// 2) 榜单不再受它影响:分数、奖牌、名次全部回到建这个小项之前的形态。
if after := standingsRaw(t, env, client); after != baseline {
t.Fatalf("删除后榜单应逐字节回到该小项存在之前:\n之前 %s\n之后 %s", baseline, after)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("删除后 A 不该再有分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("删除后 B 不该再有分,得到 %v", got)
}
if gold := teamGold(t, env, client, ids.teamA); gold != 0 {
t.Fatalf("删除后 A 不该再有金牌,得到 %d", gold)
}
// 3) 派生数据随之消失(外键级联),下钻不会再列出一堆查不到出处的名次。
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, eventID); n != 0 {
t.Fatalf("删除小项后 channel_ranks 应随外键级联清空,得到 %d 行", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, eventID); n != 0 {
t.Fatalf("删除小项后 channel_manual_ranks 应随外键级联清空,得到 %d 行", n)
}
// 4) 榜单变了就必须推给大屏:只有 events void 一条信号时,大屏会一直停在旧分上。
if after := standingsChangeRows(t, env); after <= standingsBefore {
t.Fatalf("删除小项应推一次 standings:之前 %d 条,之后 %d 条", standingsBefore, after)
}
if n := changeLogCount(t, env, "events", "void"); n != 1 {
t.Fatalf("删除小项应照旧写 1 条 events void 变更,得到 %d 条", n)
}
}
// 守卫没被改动:有报名数据的小项仍然删不掉,且被拦下时一条流水都不许作废。
func TestDeleteEventStillBlockedByEntries(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "guard",
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"},
)
ledgerBefore := ledgerActiveDump(t, env)
standingsBefore := standingsRaw(t, env, client)
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", ids.event), nil, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("有报名数据的小项应 409 且报错可读,得到 %d %+v", status, env2.Error)
}
if after := ledgerActiveDump(t, env); after != ledgerBefore {
t.Fatalf("被拦下的删除不该动作何流水:\n之前 %s\n之后 %s", ledgerBefore, after)
}
if after := standingsRaw(t, env, client); after != standingsBefore {
t.Fatalf("被拦下的删除不该动榜单:\n之前 %s\n之后 %s", standingsBefore, after)
}
}
@@ -0,0 +1,61 @@
package httpapi_test
import (
"net/http"
"testing"
)
// 末位归零的端到端回归(任务 F):现场模板勾着 last_place_zero=1,7 人里三人并列第 5
// (占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分不该被一起抹掉——修复前这里是 10/8/6/3/**0/0/0**。
// 走的是真实接口:存草稿 → 发布 → 写 score_ledger → 积分榜,覆盖引擎之外的那一段。
func TestLastPlaceZeroTieCrossingKeepsEarnedPointsE2E(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, `UPDATE scoring_profiles SET last_place_zero = 1 WHERE code = 'individual_top6'`)
// 补 5 个单位 + 7 条报名项(A、B 用 seed 的)。
entries := []int64{ids.entryA, ids.entryB}
codes := []string{"A", "B", "C", "D", "E", "F", "G"}
for i := 2; i < 7; i++ {
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, 1)`,
codes[i], "单位"+codes[i], codes[i], i)
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, "选手"+codes[i], teamID)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
entries = append(entries, entryID)
}
// 成绩 1,2,3,4,5,5,5 → 名次 1,2,3,4,5,5,5(三人并列第 5,占 5/6/7)。
values := []float64{1, 2, 3, 4, 5, 5, 5}
results := make([]map[string]any, 0, len(entries))
for i, entryID := range entries {
results = append(results, map[string]any{"entryId": entryID, "resultValue": values[i], "status": "ok"})
}
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft",
map[string]any{"eventId": ids.event, "results": results}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
points := map[string]float64{}
for _, row := range decodeData[[]map[string]any](t, env2) {
points[row["teamCode"].(string)] = row["totalPoints"].(float64)
}
// 个人前六名表 10/8/6/3/2/1:三个并列第 5 的单位都拿第 5 名的 2 分,末位那一档(第 7 名)
// 本来就没配分,所以这里看不到任何「被归零」的痕迹。
want := map[string]float64{"A": 10, "B": 8, "C": 6, "D": 3, "E": 2, "F": 2, "G": 2}
for code, w := range want {
if points[code] != w {
t.Errorf("单位 %s 总分 = %v,期望 %v(完整:%v)", code, points[code], w, points)
}
}
}
@@ -0,0 +1,250 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住工单 E 的失败场景:「分值表档位」与「录取档位数(awardRanks)」不一致时的静默截断。
//
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 而保存模板(repo.go 的 replaceProfileRules)原先只看名次是否重复、是否 >= 1,
// 从不看两边的档位数是否对得上:配到第 7 档、录取名次还留着 6 时,7 档照样落库——
// 模板页上第 7 档写着 2 分,榜上恒为 0 分,界面上看不出任何异常(审查 01 P1)。
// 现在保存与规则包导入两条路都在写入前拒收,并给出同一句人话说明。
// awardRanks = 0 表示不按名次封顶(既有语义):必须继续放行,且第 7 档真能得分。
// sevenRankRules 是配到第 7 档的分值表(第 7 档 2 分);与前六档分值互不相同,便于按分数定位名次。
func sevenRankRules() []map[string]any {
return []map[string]any{
{"rank": 1, "points": 10}, {"rank": 2, "points": 8}, {"rank": 3, "points": 6},
{"rank": 4, "points": 5}, {"rank": 5, "points": 4}, {"rank": 6, "points": 3},
{"rank": 7, "points": 2},
}
}
// sixRankRules 是种子模板 individual_top6 的现行六档分值表,用于对照「正好覆盖」。
func sixRankRules() []map[string]any {
return sevenRankRules()[:6]
}
// addIndividualEntries 给种子小项补 n 个运动员与报名项,返回新增的报名项 id
// (个人项目一人一条,沿用种子里的单位与组别)。
func addIndividualEntries(t *testing.T, env *testEnv, ids seedIDs, n int) []int64 {
t.Helper()
out := make([]int64, 0, n)
for i := 0; i < n; i++ {
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
fmt.Sprintf("补录%d", i), ids.teamA)
out = append(out, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athlete))
}
return out
}
// 甲:分值表配到第 7 档、录取名次只到 6 → 新建与更新都必须拒绝,且库里一个字节都不动。
func TestProfileAwardRanksMustCoverRuleRanks(t *testing.T) {
env, _, client := recorderWithSeed(t)
// 新建:改前是 200 + 7 档全落库(第 7 档永远算 0 分)。
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-TRUNC", "name": "录取六档配到七档", "awardRanks": 6, "rules": sevenRankRules(),
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("awardRanks=6 + 第 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
// 文案要能让现场自己改:点明超出的档位、当前录取名次、以及「0 表示不封顶」这个出口。
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
if !strings.Contains(resp.Error.Message, want) {
t.Fatalf("400 文案里缺少 %q:%q", want, resp.Error.Message)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-TRUNC'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 更新:带上同一份七档分值表,同样拒绝,且模板姓名次表都不许改。
before := profileRuleRanks(t, env, client, 1)
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sevenRankRules(),
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新成 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "第 7 档") {
t.Fatalf("更新路径的 400 文案也应点明超出的档位,得到 %q", resp.Error.Message)
}
if after := profileRuleRanks(t, env, client, 1); len(after) != len(before) {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
// 更新但**不带** rules:只把录取名次调小,一样会把库里已有的第 6 档截断,同样要拦。
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 5,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("把 awardRanks 调小到 5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "第 6 档") {
t.Fatalf("文案应点明库里已有的超档档位,得到 %q", resp.Error.Message)
}
var stored int
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = 1`).Scan(&stored); err != nil {
t.Fatalf("读录取名次: %v", err)
}
if stored != 6 {
t.Fatalf("被拒的更新不该改录取名次,得到 %d", stored)
}
// 对照:正好覆盖(第 6 档 + awardRanks=6)与不封顶(第 7 档 + awardRanks=0)都必须放行。
if status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sixRankRules(),
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("正好覆盖应 200,得到 %d %+v", status, resp.Error)
}
if status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(),
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("awardRanks=0 + 第 7 档应 200,得到 %d %+v", status, resp.Error)
}
}
// 乙:awardRanks=0 表示不封顶——保存要成功,而且第 7 档必须**真能得分**。
// 这条走完整链路(保存模板 → 把它挂到小项的计分通道 → 录 7 条成绩 → 发布 → 读流水),
// 只测校验函数的话,说不出「第 7 档到底算不算分」。
func TestProfileAwardRanksZeroScoresSeventhPlace(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(),
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("awardRanks=0 + 第 7 档应保存成功,得到 %d %+v", status, resp.Error)
}
saved := decodeData[map[string]any](t, resp)
profileID := int64(saved["id"].(float64))
if saved["awardRanks"] != float64(0) {
t.Fatalf("awardRanks=0 应原样落库,得到 %v", saved["awardRanks"])
}
if ranks := profileRuleRanks(t, env, client, profileID); len(ranks) != 7 {
t.Fatalf("7 档分值表应全部保留,得到 %v", ranks)
}
env.exec(t, `UPDATE event_score_channels SET profile_id = ? WHERE event_id = ?`, profileID, ids.event)
extra := addIndividualEntries(t, env, ids, 5)
// 成绩按录入顺序递增,7 人正好排出第 1~7 名(第 7 名成绩最差)。
publishResults(t, env, client, ids.event, "award-uncapped",
map[string]any{"entryId": ids.entryA, "resultValue": 10, "resultText": "10", "unit": "秒", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11, "resultText": "11", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[0], "resultValue": 12, "resultText": "12", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[1], "resultValue": 13, "resultText": "13", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[2], "resultValue": 14, "resultText": "14", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[3], "resultValue": 15, "resultText": "15", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[4], "resultValue": 16, "resultText": "16", "unit": "秒", "status": "ok"},
)
// 七个名次各拿一档分:第 7 名拿到 2 分就证明第 7 档没被截断。
want := []struct {
entry int64
points []float64
}{
{ids.entryA, []float64{10}},
{ids.entryB, []float64{8}},
{extra[0], []float64{6}},
{extra[1], []float64{5}},
{extra[2], []float64{4}},
{extra[3], []float64{3}},
{extra[4], []float64{2}},
}
for i, tc := range want {
if pts := activeLedgerPoints(t, env, tc.entry); !equalFloats(pts, tc.points) {
t.Fatalf("第 %d 名的报名项 %d 应得 %v 分,得到 %v", i+1, tc.entry, tc.points, pts)
}
}
}
// 规则包导入路径同一口径:YAML 里的 awardRanks 与 profiles[].rules 不一致时,
// 导入必须整体拒收(改前会安静导入,第 7 档在库里永远算 0 分)。
func TestRulesImportRejectsAwardRanksOverflow(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
const pack = `format: scoreboard-rules
formatVersion: 1
meta: { code: award-overflow, name: 第 7 档超出录取名次 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 5 }
- { rank: 5, points: 4 }
- { rank: 6, points: 3 }
- { rank: 7, points: 2 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
`
before := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusBadRequest {
t.Fatalf("导入第 7 档 + awardRanks=6 的规则包应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "第 7 档") {
t.Fatalf("导入失败的提示应点明超出的档位,得到 %s", raw)
}
after := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`)
if after != before {
t.Fatalf("被拒的导入不该动库里的分值表,得到 %d → %d 档", before, after)
}
// 对照:同一份包把录取名次改成 0(不封顶)就能导入。
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import",
[]byte(strings.Replace(pack, "awardRanks: 6", "awardRanks: 0", 1)), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("awardRanks=0 的同一份规则包应导入成功,得到 %d %s", status, raw)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`); got != 7 {
t.Fatalf("导入成功后应有 7 档,得到 %d", got)
}
}
// 库里**已经存在**的越档模板(修好之前从模板页存下的那种)必须照常导得出来:
// GET /api/rules/export 与赛事项目包是现场备份与排障的入口,把它们一起 500 掉是更大的事故
// (同类伤疤:TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。
// 所以这条检查只在写入侧生效:导出原样给出,再导回来才拒。
func TestLegacyAwardRanksOverflowStillExports(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 绕开接口直接写库,模拟「修好之前存下的」第 7 档 + awardRanks 6(现在两条写入路径都已拦下)。
env.exec(t, `INSERT INTO scoring_profile_rules (profile_id, rank, points)
SELECT id, 7, 2 FROM scoring_profiles WHERE code = 'individual_top6'`)
exported := env.exportRulesYAML(t, client)
if !strings.Contains(exported, "rank: 7") {
t.Fatalf("越档模板应原样导出,实际\n%s", exported)
}
// 同一份导出内容导回来:写入侧必须拒,且提示点明超档的那一档。
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(exported), "application/yaml", true)
if status != http.StatusBadRequest {
t.Fatalf("把导出的越档规则包导回来应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "第 7 档") {
t.Fatalf("提示应点明超出的档位,得到 %s", raw)
}
}
+43 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -1290,6 +1291,29 @@ func validateAwardRanks(awardRanks int) error {
return nil
}
// validateProfileAwardCoverage 拦住「分值表档位超出录取名次」的静默截断。
//
// 与上一条不同,这里不是字段取值范围的问题,而是两个字段**合起来**不成立:
// award_ranks > 0 时算分引擎把超出名次的档位一律当 0 分(domain/scoring.go 的 basePoints),
// 保存侧却从不校验两边的档位数,于是「分值表配到第 7 档、录取名次还留着 6」能安静落库——
// 模板页上第 7 档明明写着 2 分,榜上却是 0 分,界面上看不出任何异常(审查 01 P1)。
// 与其让人配一份算不出分的表,不如在保存时拒绝,并把两种改法直接写进错误里。
//
// list 传「保存后实际生效的档位」:本次带了 rules 就是它,没带就是库里现有的(只改 awardRanks
// 一样能把已有档位截断)。文案与规则包导入侧共用 rules.AwardRanksOverflow,两条路给同一句话。
func validateProfileAwardCoverage(awardRanks int, list []profileRuleDTO) error {
maxRank := 0
for _, r := range list {
if r.Rank > maxRank {
maxRank = r.Rank
}
}
if msg := rules.AwardRanksOverflow(awardRanks, maxRank); msg != "" {
return badRequest(msg, nil)
}
return nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
@@ -1306,6 +1330,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
if err := validateProfileAwardCoverage(in.AwardRanks, in.Rules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1363,6 +1390,19 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
// 没带 rules 时按库里现有的档位校验:只把 awardRanks 调小,一样会把已有档位截断。
// 这一步必须在开事务之前读(赛事库只开一条连接,事务里再查会自锁)。
effectiveRules := in.Rules
if effectiveRules == nil {
cur, err := s.profileRules(ctx, id)
if err != nil {
return profileDTO{}, err
}
effectiveRules = cur
}
if err := validateProfileAwardCoverage(in.AwardRanks, effectiveRules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1400,15 +1440,15 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, list []profileRuleDTO) error {
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 {
seen := make(map[int]bool, len(list))
for _, r := range list {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
+53 -7
View File
@@ -269,6 +269,18 @@ func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
}
// deleteEvent 删除小项:作废它名下的成绩流水,再删通道 / 组别 / 小项本身。
//
// 守卫只挡「已有报名数据」,所以「只有团体名次、没有报名项」的小项(球类那种形态,
// 名次直接按单位录进 channel_manual_ranks)走得通。这类小项删除时,它产生的流水必须
// 在同一个事务里显式作废:score_ledger.event_id 是 ON DELETE SET NULL,先删小项就只把
// 外键置空(voided 仍是 0),分数留在榜上、而此后任何按 event_id 的重算都打不到这些行,
// 变成「永久残留、只能改库」(审查 01 的 P0)。
//
// 作废而不是删除:保留流水行作为审计痕迹(谁在什么时候因为什么作废)。范围与
// writeLedgerTx 的作废范围同口径——只动引擎产出的成绩分与破纪录加分,人工调整
// (source_type = 'adjustment')不在此列,它由 point_adjustments 管,删小项只把它的
// event_id 置空。
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
@@ -282,6 +294,16 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
actorID := middleware.User(ctx).ID
// 必须排在下面那条 DELETE FROM events 之前:event_id 一被置空,这条件就再也匹配不上。
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND event_id = ? AND source_type IN ('result', 'record_bonus')`,
nullID(actorID), "小项删除,本小项积分流水一并作废", id); err != nil {
return internal(err)
}
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
@@ -291,7 +313,11 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, actorID); err != nil {
return err
}
// 流水作废会直接改榜单内容:不推 standings,大屏会一直停在旧分上(要等下一次发布才刷新)。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
@@ -607,7 +633,7 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
@@ -619,14 +645,19 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
for rows.Next() {
var (
c channelDTO
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = aggregateMode
}
c.CountToTeam = boolPtr(countToTeam != 0)
c.CountToMedals = boolPtr(countToMedals != 0)
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
@@ -647,6 +678,15 @@ func channelFlag(v *bool) bool {
return v == nil || *v
}
// channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg,
// 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。
func channelAggregateMode(c channelDTO) string {
if c.AggregateMode == string(domain.AggregateSum) {
return string(domain.AggregateSum)
}
return string(domain.AggregateAvg)
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -672,14 +712,20 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
default:
return nil, badRequest("通道 basis 非法", i)
}
switch c.AggregateMode {
case "", string(domain.AggregateAvg), string(domain.AggregateSum):
default:
return nil, badRequest("通道 aggregateMode 只能是 avg/sum", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode,
count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, channelAggregateMode(c),
boolToInt(channelCountsToTeam(c)),
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
+84 -5
View File
@@ -15,6 +15,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// ---------- settings ----------
@@ -476,6 +477,7 @@ func splitSettingList(raw string) []string {
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
// 总积分榜与单位奖牌榜都要算:前者的顺序是数组顺序,后者的名次由 medalRank 带走。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
in, err := s.loadStandingsInput(ctx, q)
if err != nil {
@@ -485,7 +487,11 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.Standi
if err != nil {
return nil, err
}
return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
return dto.StandingRows(
domain.BuildTeamStandings(in),
domain.BuildMedalStandings(in),
domainTeamsFromMap(teams),
), nil
}
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
@@ -668,9 +674,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
}
// 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks),
// 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。
// 组别与导出口径一致(export.loadTeamDetailRows 的 COALESCE(c.name, ca.name, '')):
// 「按队内平均」产出的聚合流水 entry_id 为空,取不到报名项的组别,得用 ca 回推的那个。
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, '')
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, COALESCE(cat.name, ca.name),
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, ''),
l.entry_id
FROM score_ledger l
LEFT JOIN events ev ON ev.id = l.event_id
LEFT JOIN entries e ON e.id = l.entry_id
@@ -694,17 +703,21 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
defer func() { _ = rows.Close() }()
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
// 每条流水背后那条成绩的版本身份;聚合流水 / 人工调整没有报名项,留零值(追不了就不追)。
keys := make([]revisions.Key, 0, 16)
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
entryID sql.NullInt64
eventCode, eventName sql.NullString
categoryName, athleteName sql.NullString
rank sql.NullInt64
resultText sql.NullString
)
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt,
&entryID); err != nil {
return teamBreakdownDTO{}, internal(err)
}
item.EventID = i64Ptr(eventID)
@@ -718,10 +731,76 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if item.AthleteID != nil && *item.AthleteID != 0 {
item.Points *= weight
}
// 只有成绩流水本身带得上修订痕迹(见 revisions.KeyFor):破纪录加分 / 人工调整 / 聚合流水留零值。
key := revisions.Key{}
if item.EventID != nil {
key = revisions.KeyFor(item.SourceType, *item.EventID, entryID.Int64)
}
keys = append(keys, key)
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return teamBreakdownDTO{}, internal(err)
}
s.attachBreakdownRevisions(ctx, out.Items, keys)
return out, nil
}
// attachBreakdownRevisions 给明细行补上修订痕迹(谁在什么时候因为什么改过这条成绩)。
//
// 查不出来只记日志、不报错:修订痕迹是「解释这一分怎么来的」的补充信息,追溯失败不该让
// 整张明细打不开 —— 现场要看的是分,不是角标。此时字段保持 nil,前端显示为空。
//
// 操作人姓名只发给 recorder 及以上:这个接口是公开的(大屏也读它),而「谁点错的」
// 属于裁判自己要看的账,不该跟着 JSON 一起下发给未登录的访客 —— 与 /api/revisions
// 只对 recorder 开放是同一个口径。时间与原因照发:那是给观众的解释,不是人名的账。
func (s *server) attachBreakdownRevisions(ctx context.Context, items []breakdownItemDTO, keys []revisions.Key) {
if len(keys) == 0 {
return
}
trails, err := revisions.Load(ctx, s.ev, keys)
if err != nil {
s.logger.Warn("读取成绩修订痕迹失败,明细按「未修订」展示", "err", err)
return
}
showActor := middleware.User(ctx).Role.Allows(auth.RoleRecorder)
actorIDs := make([]int64, 0, 4)
byActor := make(map[int]int64)
for i, key := range keys {
trail, ok := trails[key]
if !ok {
continue
}
count := trail.Count
items[i].RevisionCount = &count
if count == 0 {
continue
}
at := trail.At
items[i].LastRevisionAt = &at
if trail.Reason != "" {
reason := trail.Reason
items[i].LastRevisionReason = &reason
}
if trail.ActorID != nil && showActor {
actorIDs = append(actorIDs, *trail.ActorID)
byActor[i] = *trail.ActorID
}
}
if len(actorIDs) == 0 || s.sys == nil {
return
}
names, err := s.userNames(ctx, actorIDs)
if err != nil {
s.logger.Warn("读取修订操作人姓名失败,只显示时间与原因", "err", err)
return
}
for i, id := range byActor {
if name := names[id]; name != "" {
items[i].LastRevisedBy = &name
}
}
}
// ---------- records ----------
+109
View File
@@ -162,6 +162,115 @@ func TestTeamBreakdownAppliesIndividualWeight(t *testing.T) {
}
}
// 单位积分明细的「组别」列不能整列变空:聚合通道(按队内平均)产出的流水 entry_id 与 result_id
// 都为空,从 entries 取不到组别,接口要按「单位在本小项只参加了一个组别」回推那一个
// (与 export.loadTeamDetailRows 的组别回推口径一致)。
func TestTeamBreakdownAggregateRowKeepsCategory(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")
// 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), 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("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
}
item := items[0].(map[string]any)
if got := item["categoryName"]; got != "男子" {
t.Fatalf("聚合流水的组别 = %v,期望 男子(按单位唯一参加的组别回推)", got)
}
// 聚合分不对应单个运动员,运动员列为空是合理的(前端降级成单位名);但组别不能跟着空。
if name, ok := item["athleteName"]; ok && name != nil {
t.Fatalf("聚合流水不该带运动员名,得到 %v", name)
}
}
// 正常发布的个人流水(走报名项)必须带齐「谁 · 哪个项目 · 哪个组别」:
// 大屏明细的「运动员」列就靠这一行数据,任何一项为空都会让现场只看到项目与名次。
func TestTeamBreakdownIndividualRowCarriesWhoAndCategory(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,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), 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("明细行数 = %d,期望 1", len(items))
}
item := items[0].(map[string]any)
for field, want := range map[string]any{
"athleteName": "张伟",
"categoryName": "男子",
"eventName": "100米跑",
"resultText": "11.58",
} {
if got := item[field]; got != want {
t.Fatalf("明细行的 %s = %v,期望 %v(大屏明细要能说清是谁在哪个项目拿了多少分)", field, got, want)
}
}
}
// 单位在同一小项参加了多个组别时回推不出组别,明细宁可留空也不猜(与导出一致)。
func TestTeamBreakdownAmbiguousAggregateCategoryStaysEmpty(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")
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
otherAthlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active)
VALUES ('王芳', '女', ?, 1)`, ids.teamA)
env.exec(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, otherCat, ids.teamA, otherAthlete)
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), 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("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
}
if got := items[0].(map[string]any)["categoryName"]; got != nil {
t.Fatalf("两个组别都参加时组别应留空(不猜),得到 %v", got)
}
}
// 只排名不计分(scored=0)的项目即使配了计分通道,也不得在大屏奖牌榜产出奖牌;
// 导出侧已按 ev.scored = 1 过滤,两边口径必须一致,否则奖牌榜与成绩册对不上。
func TestStandingsExcludesUnscoredEventMedals(t *testing.T) {
+84 -31
View File
@@ -2,6 +2,9 @@ package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
@@ -129,17 +132,18 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
res, err := rules.Apply(s.ev, cs)
res, rows, err := s.applyRulesAndRecompute(r.Context(), cs, middlewareUserID(r))
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分与名次必须全量重算。
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
if err != nil {
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err)
// 配置本身就不成立(compare=none 的小项被配上 basis=result 等):落地前就拒了,
// 一个字节都没写,属于 400 而不是「服务端异常」。
var conflict *rules.CompareConflictError
if errors.As(err, &conflict) {
return nil, badRequest(conflict.Error(), nil)
}
// 其余情况是重算在某个小项上失败,整包(规则 + 积分 + 名次)已跟着回滚,
// 榜单维持导入前的状态。原始错误带着「哪个小项、因为什么」,由 detail 一起回给现场。
s.logger.Error("规则导入失败,已整体回滚", "err", err)
return nil, internalMsg("规则包未导入:积分重算失败,本次规则与积分改动已整体回滚,榜单维持导入前的状态(原因见后)", err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return rulesImportResult{
@@ -151,22 +155,80 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
// 返回写入的积分流水条数,供导入结果回报。
// applyRulesAndRecompute 把「规则包落地」与「全量重算」放进**同一个事务**,成功才提交。
//
// 这两步必须同事务:换通道会先删 event_score_channels,而 channel_ranks.channel_id 挂的是
// `ON DELETE CASCADE`,名次会跟着被清空、靠紧接着的重算重建。以前两步各自提交,重算一失败
// (例如某个小项配不出名次)名次表就停在空表状态:榜单「分还在、奖牌全 0」,而且自愈与重试导入
// 都会因同一个小项再次失败,无人可解(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。
// 现在要么整体成功,要么整体回滚到导入前。
//
// 返回落地统计与重算写回的流水条数(后者供导入结果回报)。
func (s *server) applyRulesAndRecompute(ctx context.Context, cs *rules.ChangeSet, actorID int64) (*rules.ApplyResult, int, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, 0, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := rules.ApplyTx(tx, cs)
if err != nil {
return nil, 0, err
}
rows, err := s.recomputeAllEventsTx(ctx, tx, actorID)
if err != nil {
return nil, 0, err
}
if err := tx.Commit(); err != nil {
return nil, 0, internal(err)
}
return res, rows, nil
}
// recomputeAllEvents 全量重算所有项目的积分流水与名次(自带事务),用于改积分模板等
// 会改变分值的场景。返回写入的积分流水条数,供调用方回报。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
var ids []int64
defer func() { _ = tx.Rollback() }()
total, err := s.recomputeAllEventsTx(ctx, tx, actorID)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
// recomputeAllEventsTx 在调用方的事务里全量重算,自己不提交。
//
// 整个重算共用调用方这一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态;
// 规则包导入正是靠这一点把「换通道」与「重算」绑在一起(见 applyRulesAndRecompute)。
//
// 每个小项的失败都带上小项编码与名称:只回一句「basis=result 的通道要求小项配置 compare=asc/desc」,
// 现场在一场几十上百个小项的赛事里定位不到是哪一个,也就无从修起。
func (s *server) recomputeAllEventsTx(ctx context.Context, tx *sql.Tx, actorID int64) (int, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, code, name FROM events ORDER BY id`)
if err != nil {
return 0, err
}
type target struct {
id int64
code string
name string
}
var events []target
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
var t target
if err := rows.Scan(&t.id, &t.code, &t.name); err != nil {
_ = rows.Close()
return 0, err
}
ids = append(ids, id)
events = append(events, t)
}
if err := rows.Close(); err != nil {
return 0, err
@@ -175,21 +237,15 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
return 0, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
total := 0
for _, eventID := range ids {
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
for _, ev := range events {
score, err := recomputeLedgerTx(ctx, tx, ev.id, 0, actorID, "rules-import")
if err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)重算失败:%w", ev.code, ev.name, err)
}
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)回写名次失败:%w", ev.code, ev.name, err)
}
total += len(score.Ledger)
}
@@ -197,8 +253,5 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
+190 -25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
@@ -183,8 +184,152 @@ func TestRulesImportReturnsContractFields(t *testing.T) {
}
}
// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
// rulesPackTemplateOnly 是一份「只按 eventType 匹配、不看 compare」的规则包:individual 小项
// 一律被配上 basis=result。库里若存在 compare=none(名次靠人工录入)的个人小项,这份模板
// 就会给它配上一条根本跑不起来的通道——这正是审查 04 P0-1 的触发条件。
// 模板分值与本仓库默认模板一致,保证测试里的差异只出现在通道上。
const rulesPackTemplateOnly = `format: scoreboard-rules
formatVersion: 1
meta: { code: test-template-only, name: 只按 eventType 匹配的规则包 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 3 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
- { rank: 3, points: 8 }
- { rank: 4, points: 4 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
`
// rulesPackFixedBroken 是同一份规则包加上「坏小项改用 basis=manual」的 override:
// 导入失败提示就是这么让现场自己救回来的(compare=none 的小项只能用人工名次通道)。
const rulesPackFixedBroken = rulesPackTemplateOnly + `
eventOverrides:
- eventCode: Z_BROKEN
channels:
- { scope: athlete, basis: manual, profile: individual_top6 }
`
// seedCompareNoneEvent 造一个 compare=none 的个人小项并发布一条人工名次。
// 发布走真实接口,好让它占住一行 channel_ranks——那份名次就是「被级联清空就补不回来」的数据。
func seedCompareNoneEvent(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, code, name string) int64 {
t.Helper()
eventID := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name)
categoryID := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, eventID)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'manual', ?, 0)`, eventID, profileID)
entryID := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, categoryID, ids.teamA, ids.athleteA)
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID,
"requestId": "draft-" + code,
"results": []map[string]any{{"entryId": entryID, "rank": 1, "status": "ok"}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("保存手工名次应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": eventID,
"requestId": "publish-" + code,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布手工名次应 200,得到 %d %+v", status, resp.Error)
}
return eventID
}
// 审查 04 P0-1 的回归:库里存在 compare=none 的个人小项时导入只按 eventType 匹配的规则包。
//
// 导入前 channel_ranks 有 3 行(M100 两条 + 手工小项一条)、A 队 1 金;旧代码在这里会把通道
// 先换掉(channel_ranks 被外键级联清空)、随后重算在手工小项上报 ErrCompareRequired,
// 结果是 3 行 → 0 行、金牌 1 → 0,而自愈与「重试导入」都以同一个错误失败,无人可解。
// 修完之后:要么整体成功,要么整体回滚到导入前;名次表**不允许**变成空的。
func TestRulesImportWithCompareNoneEventRollsBackOrSucceeds(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")
env.publishTwoResults(t, client, ids)
seedCompareNoneEvent(t, env, client, ids, "Z_MANUAL", "手榴弹投准")
beforeRanks := channelRankCount(t, env)
beforeMedals := medalsByTeam(t, env)
beforePoints := env.ledgerPointsByTeam(t)
if beforeRanks != 3 {
t.Fatalf("导入前应有 3 行通道名次(M100 两条 + 手工小项一条),得到 %d", beforeRanks)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true)
// 两种结局都算对:整体成功,或整体回滚回导入前。名次表变空就是失败。
if status == http.StatusOK {
if got := channelRankCount(t, env); got == 0 {
t.Fatalf("导入返回成功却把通道名次清空了(%d → 0)", beforeRanks)
}
return
}
if status < http.StatusBadRequest {
t.Fatalf("导入既没成功也没失败,status=%d %s", status, raw)
}
// 报错要说人话:哪个小项、因为什么。
for _, want := range []string{"Z_MANUAL", "手榴弹投准", "compare=none"} {
if !strings.Contains(string(raw), want) {
t.Errorf("导入失败提示缺少 %q:%s", want, raw)
}
}
// 名次表必须回到导入前的状态(不是空的)。
if got := channelRankCount(t, env); got != beforeRanks {
t.Fatalf("导入失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got)
}
if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) {
t.Fatalf("导入失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got)
}
afterPoints := env.ledgerPointsByTeam(t)
if !reflect.DeepEqual(afterPoints, beforePoints) {
t.Fatalf("导入失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints)
}
}
// 审查 04 P0-1 的第二个回归:重算失败时,「换通道」必须一起回滚。
//
// 现场形态是旧代码留下的:库里已经有一个跑不起来的小项(compare=none 却配了 basis=result 通道),
// 规则包没有覆盖它(模板只匹配 individual,它是 match 类型),于是全量重算必然在它身上失败。
// 旧代码此时通道替换已经提交,M100 的 channel_ranks 被级联清空 → 榜单「分还在、奖牌全 0」,
// 重试导入以同一个错误失败。修完后:整包回滚(名次表维持原样),
// 并且按提示给坏小项写一条 override 再导入就能修好——恢复得回来。
func TestRulesImportRecomputeFailureRollsBackChannelReplacement(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
@@ -192,17 +337,17 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 坏小项:compare=none(排不出名次)却配了 basis=result 的通道,还有一条已发布成绩。
// 规则包的 individual 模板匹配不到它(event_type=match),所以它的通道不会被导入覆盖。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'match', 'auto', 'none', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
// 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
// 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
brokenEntry := env.insert(t,
@@ -211,33 +356,53 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
before := env.ledgerPointsByTeam(t)
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道")
beforeRanks := channelRankCount(t, env)
beforeMedals := medalsByTeam(t, env)
beforePoints := env.ledgerPointsByTeam(t)
if beforeRanks != 2 {
t.Fatalf("导入前应有 2 行通道名次(M100 两条),得到 %d", beforeRanks)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
t.Fatalf("坏小项让重算失败,应 500,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "积分重算失败") {
t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
// 人话:必须指名是哪个小项、因为什么,现场才定位得到。
for _, want := range []string{"Z_BROKEN", "损坏项目", "compare"} {
if !strings.Contains(string(raw), want) {
t.Errorf("重算失败提示缺少 %q:%s", want, raw)
}
}
// 换通道与重算同事务:失败即整体回滚,名次表回到导入前(不能变成空的)。
if got := channelRankCount(t, env); got != beforeRanks {
t.Fatalf("重算失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got)
}
if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) {
t.Fatalf("重算失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got)
}
// 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
var code string
if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
JOIN scoring_profiles p ON p.id = c.profile_id
WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
t.Fatalf("查询导入后的计分通道: %v", err)
}
if code != "team_top6" {
t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
if code != "individual_top6" {
t.Fatalf("通道替换也必须回滚,得到 %q", code)
}
after := env.ledgerPointsByTeam(t)
if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
afterPoints := env.ledgerPointsByTeam(t)
if !reflect.DeepEqual(afterPoints, beforePoints) {
t.Fatalf("重算失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints)
}
// 可恢复:按提示给坏小项补一条 basis=manual 的 override,再导入就成功,且名次表被重建。
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackFixedBroken), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("修正坏小项后导入应 200,得到 %d %s", status, raw)
}
if got := channelRankCount(t, env); got <= beforeRanks {
t.Fatalf("修正后名次表应被重建(含 Z_BROKEN 的一行),得到 %d 行", got)
}
if got := medalsByTeam(t, env); got[ids.teamA].Gold == 0 {
t.Fatalf("修正后奖牌榜应恢复,得到 %+v", got)
}
}
+56 -2
View File
@@ -86,7 +86,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
@@ -98,14 +98,19 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
for rows.Next() {
var (
c domain.ScoreChannel
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = domain.AggregateSum
}
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
@@ -545,14 +550,30 @@ func resultStatusPtr(s domain.ResultStatus) *string {
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
//
// 这一列还是 manual 通道的**输入**(个人手工名次走它、团体报名项的团体名次也走它兜底),
// 所以命中「人工名次的录入位」时必须让路,否则同一份数据第二次发布会算出另一个结果:
//
// - 小项配了 [athlete, manual]:每一条成绩的 results.rank 都是操作员填的输入,
// 自动名次覆盖它 → 人工通道下一轮读到的是自动名次(工单 A-2:A 队 18 → 20 分);
// - 小项配了 [team, manual] 且该行是**团体报名项**(AthleteID == 0):它的 results.rank
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
inputs, err := manualRankInputEvents(ctx, tx, ranked)
if err != nil {
return err
}
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
continue
}
cur, ok := best[rr.ResultID]
if !ok || preferComputedRank(rr, cur) {
best[rr.ResultID] = rr
@@ -566,6 +587,39 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
return nil
}
// manualRankInputs 描述一个小项把 results.rank 当作「人工名次录入位」的两种通道形态。
type manualRankInputs struct {
athleteManual bool // [athlete, manual]:每条成绩的 results.rank 都是人工录入的个人名次
teamManual bool // [team, manual]:团体报名项的 results.rank 是人工录入的团体名次
}
// manualRankInputEvents 查出这些名次所属小项里,哪些把 results.rank 当输入用。
// 只查 ranked 里出现的小项:没有名次可写回时不需要这一趟查询,
// 而「有没有 manual 通道」必须看库(不能只看 ranked——名次还没录时该通道不产出明细)。
func manualRankInputEvents(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) (map[int64]manualRankInputs, error) {
if len(ranked) == 0 {
return nil, nil
}
out := make(map[int64]manualRankInputs)
for _, rr := range ranked {
if rr.EventID == 0 {
continue
}
if _, done := out[rr.EventID]; done {
continue
}
var athlete, team int
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(CASE WHEN scope = 'athlete' AND basis = 'manual' THEN 1 ELSE 0 END), 0),
COALESCE(MAX(CASE WHEN scope = 'team' AND basis = 'manual' THEN 1 ELSE 0 END), 0)
FROM event_score_channels WHERE event_id = ?`, rr.EventID).Scan(&athlete, &team); err != nil {
return nil, internal(err)
}
out[rr.EventID] = manualRankInputs{athleteManual: athlete != 0, teamManual: team != 0}
}
return out, nil
}
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
func preferComputedRank(a, b domain.RankedResult) bool {
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
@@ -0,0 +1,103 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"testing"
)
// TestStandingsMedalRankIndependentOfPoints 是 06 报告 P1④ 的接口级回归用例
// (主人 2026-09-13 拍板:单位奖牌榜独立按金→银→铜排序,与积分榜解耦)。
//
// 造数:A 拿 1 金 10 分,B 拿 1 银 8 分,再给 B 记一笔 30 分的人工调整(不发奖牌)。
// 期望:总积分榜仍按总分排(B 38 分第一),单位奖牌榜把 A 排第一(gold 1 > 0)。
// 修前两个榜同序,奖牌榜会把金牌 1 枚的 A 排在金牌 0 枚的 B 后面,正是报告里的现象。
func TestStandingsMedalRankIndependentOfPoints(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")
if status, body := 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); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, body.Error)
}
if status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, body.Error)
}
// 人工调整只加分不发奖牌,正好用来造「金牌多、总分低」。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
if status, body := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamB, "delta": 30, "category": "bonus", "reason": "接口用例造数",
}, true); status != http.StatusOK {
t.Fatalf("人工调整应 200,得到 %d %+v", status, body.Error)
}
// REST:GET /api/standings 的 rank 与 medalRank 必须是两套口径。
_, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
assertMedalRankDecoupled(t, "REST /api/standings", decodeData[[]map[string]any](t, resp))
// 大屏那条路:standings 变更的载荷(change_log 里固化的那份,WS 原样推送)同源。
var payload string
if err := env.ev.QueryRow(`SELECT payload_json FROM change_log
WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
t.Fatalf("读取 standings 变更: %v", err)
}
var pushed []map[string]any
if err := json.Unmarshal([]byte(payload), &pushed); err != nil {
t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload)
}
assertMedalRankDecoupled(t, "WS standings 载荷", pushed)
}
// assertMedalRankDecoupled 断言榜单载荷里「积分榜按总分、奖牌榜按金牌」两套名次并存。
func assertMedalRankDecoupled(t *testing.T, label string, rows []map[string]any) {
t.Helper()
// 字段缺失时给一句人话,不要用裸类型断言(旧服务端的载荷里没有 medalRank)。
num := func(row map[string]any, key string) float64 {
value, ok := row[key].(float64)
if !ok {
t.Fatalf("%s:字段 %q 不是数字(缺失 / 类型不对):%v", label, key, row[key])
}
return value
}
byCode := make(map[string]map[string]any, len(rows))
for _, row := range rows {
code, _ := row["teamCode"].(string)
byCode[code] = row
}
if len(byCode) != 2 {
t.Fatalf("%s:应含 2 个单位,得到 %d(%v)", label, len(byCode), rows)
}
a, b := byCode["A"], byCode["B"]
// 总积分榜:B 靠 30 分人工调整反超,仍是 38 分第一。
if got := num(b, "totalPoints"); got != 38 {
t.Fatalf("%s:B 总分 = %v,期望 38(8 + 调整 30)", label, got)
}
if num(b, "rank") != 1 || num(a, "rank") != 2 {
t.Fatalf("%s:总积分榜名次应 B 在 A 前(B 38 分),得到 A:%v B:%v",
label, a["rank"], b["rank"])
}
// 单位奖牌榜:A 有 1 金(B 无牌),名次必须独立算成第一。
if num(a, "gold") != 1 || num(b, "gold") != 0 {
t.Fatalf("%s:奖牌数不对,A 应 1 金、B 应 0 金:%v %v", label, a, b)
}
if num(a, "medalRank") != 1 || num(b, "medalRank") != 2 {
t.Fatalf("%s:单位奖牌榜名次应 A 第一、B 第二(按金牌),得到 A:%v B:%v",
label, a["medalRank"], b["medalRank"])
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
}
assertKeys(t, "standings[0]", rows[0], []string{
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
"rank", "medalRank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
})
}