Files
AskaEth 877ff15823 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 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

409 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
// decodeRaw 解析 doRaw 返回的完整响应体({ok,data} 信封)。
func decodeRaw[T any](t *testing.T, raw []byte) T {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
return decodeData[T](t, env)
}
// exportRulesYAML 取当前规则包(导出与导入共用同一序列化,保证往返可解析)。
func (e *testEnv) exportRulesYAML(t *testing.T, client *http.Client) string {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
return string(body)
}
// publishTwoResults 录入两条成绩并发布,返回两条流水的分值(按单位)。
func (e *testEnv) publishTwoResults(t *testing.T, client *http.Client, ids seedIDs) {
t.Helper()
status, env2 := e.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-rules-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 = e.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-rules-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// ledgerPointsByTeam 读取当前有效流水的「单位 → 分值」。
func (e *testEnv) ledgerPointsByTeam(t *testing.T) map[int64]float64 {
t.Helper()
rows, err := e.ev.Query(`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result'`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := map[int64]float64{}
for rows.Next() {
var team int64
var points float64
if err := rows.Scan(&team, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[team] = points
}
return out
}
// 规则导入后必须回写名次:分值一变,results.rank 也要跟着新规则重算(P0-5)。
func TestRulesImportRewritesRanks(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)
// 模拟名次过期/写坏,导入规则包后应被重算覆盖。
env.exec(t, `UPDATE results SET rank = 99 WHERE event_id = ?`, ids.event)
pack := env.exportRulesYAML(t, client)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
rows, err := env.ev.Query(`SELECT entry_id, rank FROM results WHERE event_id = ? ORDER BY entry_id`, ids.event)
if err != nil {
t.Fatalf("查询名次: %v", err)
}
defer func() { _ = rows.Close() }()
ranks := map[int64]int64{}
for rows.Next() {
var entryID, rank int64
if err := rows.Scan(&entryID, &rank); err != nil {
t.Fatalf("扫描名次: %v", err)
}
ranks[entryID] = rank
}
if ranks[ids.entryA] != 1 || ranks[ids.entryB] != 2 {
t.Fatalf("导入后名次应重算为 A=1 B=2,得到 %+v", ranks)
}
}
// 预览与落地响应必须带上 compatible / affectedEventCount / recalculatedLedgerRows(P1-11)。
func TestRulesImportReturnsContractFields(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)
// 把 M100 的计分通道换成团队模板:制造一个「影响 1 个小项」的差异。
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道,导出内容:\n%s", pack)
}
// 只改模板分值:引用了该模板的小项同样算「被影响」。
pointsChanged := strings.Replace(pack, "points: 10", "points: 11", 1)
if pointsChanged == pack {
t.Fatalf("规则包里没有可替换的分值,导出内容:\n%s", pack)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(pointsChanged), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
pointsPreview := decodeRaw[map[string]any](t, raw)
if count, _ := pointsPreview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("只改模板分值时 affectedEventCount 应为 1,得到 %+v", pointsPreview["affectedEventCount"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
preview := decodeRaw[map[string]any](t, raw)
if preview["compatible"] != true {
t.Fatalf("预览 compatible 应为 true,得到 %+v", preview["compatible"])
}
if count, _ := preview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("预览 affectedEventCount 应为 1,得到 %+v", preview["affectedEventCount"])
}
if channels, _ := preview["channels"].([]any); len(channels) != 1 {
t.Fatalf("预览 channels 应含 1 个小项,得到 %+v", preview["channels"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
applied := decodeRaw[map[string]any](t, raw)
if applied["compatible"] != true {
t.Fatalf("导入结果 compatible 应为 true,得到 %+v", applied["compatible"])
}
if count, _ := applied["affectedEventCount"].(float64); count != 1 {
t.Fatalf("导入结果 affectedEventCount 应为 1,得到 %+v", applied["affectedEventCount"])
}
if rows, _ := applied["recalculatedLedgerRows"].(float64); rows != 2 {
t.Fatalf("导入结果 recalculatedLedgerRows 应为 2,得到 %+v", applied["recalculatedLedgerRows"])
}
// 落地响应也要带差异明细,否则前端会把预览覆盖成「无差异」。
if channels, _ := applied["channels"].([]any); len(channels) != 1 {
t.Fatalf("导入结果 channels 应含 1 个小项,得到 %+v", applied["channels"])
}
// 换成团队模板后分值应变成 15/10,证明重算确实落库。
points := env.ledgerPointsByTeam(t)
if points[ids.teamA] != 15 || points[ids.teamB] != 10 {
t.Fatalf("导入后流水分值应为 A=15 B=10,得到 %+v", points)
}
}
// 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")
client, _ := env.client()
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)
}
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,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, broken, brokenCat, ids.teamA, ids.athleteA)
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)
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(rulesPackTemplateOnly), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("坏小项让重算失败,应 500,得到 %d %s", status, 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)
}
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 != "individual_top6" {
t.Fatalf("通道替换也必须回滚,得到 %q", code)
}
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)
}
}