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

+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) {