fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。
## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。
主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试
## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。
## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败
## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
1 parent
db94d9c119
commit
6841cf49aa
147 files changed
+8141
-423
No files matched your search
@@ -4,16 +4,24 @@ import "errors"
|
||||
|
||||
// 引擎的哨兵错误:调用方(store / httpapi)据此区分「配置错」与「数据错」。
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在")
|
||||
ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis")
|
||||
ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc")
|
||||
ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在")
|
||||
ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis")
|
||||
ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc")
|
||||
|
||||
// 下面这 5 个是「无返回方」的哨兵:全仓(含 _test.go)只有
|
||||
// internal/httpapi/response.go 的错误映射分支在引用它们,本包没有任何一行会返回。
|
||||
// 来源是已经删掉的流水作废 / 增量接口(人工调整现在走 httpapi 自己的
|
||||
// stateInvalid / badRequest),映射分支实际上是悬空的。
|
||||
// 它们留在 domain 里的唯一理由是让那份映射仍能编译;新增功能时不要照着它们造错误,
|
||||
// 真要用先接线返回方。删掉需同步删映射(response.go)。
|
||||
ErrLedgerNotFound = errors.New("domain: 积分流水不存在")
|
||||
ErrLedgerAlreadyVoided = errors.New("domain: 积分流水已作废,不能重复作废")
|
||||
ErrDuplicateLedgerID = errors.New("domain: 积分流水 id 重复")
|
||||
ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用")
|
||||
ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由")
|
||||
ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作")
|
||||
|
||||
// ErrPointsOverrideUnsupported:成绩上的 pointsOverride 不被引擎读取。
|
||||
// 与其静默忽略(设置成功但毫无效果),不如显式报错;加减分请走人工积分调整。
|
||||
// 这条有真正的返回方(scoring.go 的 ScoreEvent),与上面那组不同。
|
||||
ErrPointsOverrideUnsupported = errors.New("domain: 成绩不接受 pointsOverride,请改用人工积分调整")
|
||||
)
|
||||
@@ -2,7 +2,8 @@ package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。
|
||||
// 本文件实现 devdocs/09-测试策略.md 第 2 节 17 条用例矩阵中的 13 条(01–07、10–15);
|
||||
// 第 8 / 9 / 17 / 18 条没有同名测试函数,第 16 条(联队)已整条移除。
|
||||
// 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。
|
||||
|
||||
// case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestExcludeTeamEventMedalsStillApplies(t *testing.T) {
|
||||
|
||||
team, _ = medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked, ExcludeTeamEventMedals: true})
|
||||
if team != [3]int{0, 0, 0} {
|
||||
t.Fatalf("全局排除团体奖牌后单位榜 = %v,期望 0", team)
|
||||
t.Fatalf("全局排除团体项目奖牌后单位榜 = %v,期望 0", team)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// C04:avg_mode 的分母在「报名项退赛 / 失格」与「成绩失格 / 没录」混在一起时的口径。
|
||||
//
|
||||
// scoring.go 的 scoreByAggregate 定下的口径是:
|
||||
// - 报名项非 registered(scratched / dq / dns):整条不进桶,既不算分子也不算分母、不占名次;
|
||||
// - 报名项正常但成绩失格 / 没录:进 all 的分母(按 0 计入),不进 valid 的分子与分母。
|
||||
//
|
||||
// 此前只有「有成绩 / 没成绩 / 没录成绩」的对照(TestAggregateAvgMode),
|
||||
// 退赛与失格混进来时分母是谁没人钉过——把退赛者算进分母会让全队平均被拉低、名次互换,
|
||||
// 而现场看到的只是榜单数字莫名其妙变了。
|
||||
func TestAggregateAvgModeDenominatorWithScratchedAndDQ(t *testing.T) {
|
||||
build := func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
// 1001:一条正常成绩 10;一条报名项退赛(带着 6,必须整条被忽略);
|
||||
// 一条报名项正常但成绩失格(all 口径要按 0 计入分母)。
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 6, entryStatus: EntryStatusScratched},
|
||||
{entryID: 3, athleteID: 3, teamID: 1001, value: 4, status: ResultStatusDQ},
|
||||
// 1002:一条正常成绩 8;一条报名项失格且成绩是 100(若被算进分母或分子,
|
||||
// 平均会被拉成 54,名次当场互换——这是本条最容易写错的地方)。
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
|
||||
{entryID: 5, athleteID: 5, teamID: 1002, value: 100, entryStatus: EntryStatusDQ},
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode AvgMode
|
||||
want map[int64]aggregateOutcome
|
||||
}{
|
||||
{
|
||||
// 分子分母都只算「报名项正常且成绩有效」的人:1001 → 10/1,1002 → 8/1。
|
||||
// compare=asc(越小越好)→ 1002 第一(15 分),1001 第二(10 分)。
|
||||
name: "valid:退赛与失格的报名项都不算分子分母",
|
||||
mode: AvgModeValid,
|
||||
want: map[int64]aggregateOutcome{
|
||||
1001: {value: 10, points: 10, rank: 2},
|
||||
1002: {value: 8, points: 15, rank: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
// 分母 = 报名项数:1001 有 2 条正常报名项(10 + 0)/ 2 = 5;1002 只有 1 条 → 8。
|
||||
// 退赛的 6 与失格的 100 都不该出现在任何一边。
|
||||
name: "all:分母是报名项数,退赛者连桶都不进",
|
||||
mode: AvgModeAll,
|
||||
want: map[int64]aggregateOutcome{
|
||||
1001: {value: 5, points: 15, rank: 1},
|
||||
1002: {value: 8, points: 10, rank: 2},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entries, results := build()
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = tc.mode
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamAggregateChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
|
||||
got := aggregateByTeam(t, es)
|
||||
for teamID, want := range tc.want {
|
||||
have, ok := got[teamID]
|
||||
if !ok {
|
||||
t.Fatalf("单位 %d 应参与排名,实际 %+v", teamID, got)
|
||||
}
|
||||
if !nearlyEqual(have.value, want.value) {
|
||||
t.Errorf("单位 %d 平均成绩 = %v,期望 %v(分子分母口径不符)", teamID, have.value, want.value)
|
||||
}
|
||||
if have.rank != want.rank || !nearlyEqual(have.points, want.points) {
|
||||
t.Errorf("单位 %d = 第 %d 名 %v 分,期望 第 %d 名 %v 分",
|
||||
teamID, have.rank, have.points, want.rank, want.points)
|
||||
}
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Errorf("参与排名的单位数 = %d,期望 %d(%+v)", len(got), len(tc.want), got)
|
||||
}
|
||||
// 流水与名次同源:每个单位的积分都能在流水里找到。
|
||||
ledger := pointsByTeam(es.Ledger)
|
||||
for teamID, want := range tc.want {
|
||||
if !nearlyEqual(ledger[teamID], want.points) {
|
||||
t.Errorf("单位 %d 流水 = %v,期望 %v", teamID, ledger[teamID], want.points)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("整队报名项全退赛:不成桶、不上榜、不得分", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 1, entryStatus: EntryStatusScratched},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 2, entryStatus: EntryStatusDQ},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 8},
|
||||
})
|
||||
for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} {
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = mode
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamAggregateChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
if got := aggregateByTeam(t, es); got[1001].value != 0 || got[1001].points != 0 || got[1001].rank != 0 {
|
||||
t.Errorf("%s:全退赛的单位不该上榜、不该得分,得到 %+v", mode, got[1001])
|
||||
}
|
||||
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1002], 15) || got[1001] != 0 {
|
||||
t.Errorf("%s:只剩 1002 时应独占第一(15 分),得到 %v", mode, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// aggregateOutcome 是一次聚合通道计分后「某个单位拿到的平均成绩 / 名次 / 积分」。
|
||||
type aggregateOutcome struct {
|
||||
value float64
|
||||
rank int
|
||||
points float64
|
||||
}
|
||||
|
||||
func aggregateByTeam(t *testing.T, es EventScore) map[int64]aggregateOutcome {
|
||||
t.Helper()
|
||||
out := make(map[int64]aggregateOutcome, len(es.Ranked))
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.ResultValue == nil {
|
||||
t.Fatalf("聚合通道的名次明细应带上平均成绩:%+v", rr)
|
||||
}
|
||||
out[rr.TeamID] = aggregateOutcome{value: *rr.ResultValue, rank: rr.Rank, points: rr.Points}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// C04:并列组「跨越录取名次边界」时分怎么算。
|
||||
//
|
||||
// 一组并列者占用的是 startRank .. startRank+size-1 这一整段名次,这段区间跨过 AwardRanks 时
|
||||
// 就出现了「组内一部分有名次、一部分没有」:
|
||||
//
|
||||
// skip 取起排名次的分(6 人表里并列第 6 的两人都拿第 6 名的分);
|
||||
// average 把区间内所有名次的分平均,没配到的名次按 0(两人并列第 6 → (1+0)/2 = 0.5);
|
||||
// higher 取组内最高分(并列第 6 → 1)。
|
||||
//
|
||||
// 三种口径在这里给出三个不同的数,而现场只看得到分数——之前没有用例钉住这条边界。
|
||||
func TestGroupPointsTieGroupCrossingAwardRanksBoundary(t *testing.T) {
|
||||
ev := individualEvent(1)
|
||||
cat := testCategory()
|
||||
top6 := ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules}
|
||||
top3 := ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
profile ScoringProfile
|
||||
start int
|
||||
size int
|
||||
want float64
|
||||
}{
|
||||
// 录取线内(占 5、6,都在前六名里):三种口径都只用到配置了的名次。
|
||||
{"两人并列第5 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 2, 1.5},
|
||||
// 跨线(占 6、7,第 7 名没配分):average 把第 7 名当 0 平均掉。
|
||||
{"两人并列第6 average 跨过录取线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 2, 0.5},
|
||||
{"两人并列第6 higher 取组内最高", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 6, 2, 1},
|
||||
{"两人并列第6 skip 取起排位", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 6, 2, 1},
|
||||
// 三人跨两格(占 5、6、7)。
|
||||
{"三人并列第5 average 跨线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 3, 1},
|
||||
{"三人并列第5 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 5, 3, 2},
|
||||
{"三人并列第6 average 只剩一格有名次", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 3, 1.0 / 3.0},
|
||||
// 整组都在录取线外:三种口径都是 0,不能出现负数或异常。
|
||||
{"两人并列第7 skip 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 7, 2, 0},
|
||||
{"两人并列第7 average 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 7, 2, 0},
|
||||
// 一组人从第 1 名一直占到第 7 名(全场并列):average 把 0 也算进去。
|
||||
{"七人并列第1 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 1, 7, 30.0 / 7.0},
|
||||
{"七人并列第1 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 1, 7, 10},
|
||||
{"七人并列第1 skip", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 1, 7, 10},
|
||||
// 录取名次更少时边界跟着走(top3 的线在第 3 名)。
|
||||
{"top3 两人并列第3 average", ScoringProfile{AwardRanks: 3, TieMode: TieAverage, Rules: top3.Rules}, 3, 2, 3},
|
||||
{"top3 两人并列第3 skip", ScoringProfile{AwardRanks: 3, TieMode: TieSkip, Rules: top3.Rules}, 3, 2, 6},
|
||||
{"top3 两人并列第3 higher", ScoringProfile{AwardRanks: 3, TieMode: TieHigher, Rules: top3.Rules}, 3, 2, 6},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := groupPoints(tc.profile, tc.start, tc.size, 6, ev, cat)
|
||||
if !nearlyEqual(got, tc.want) {
|
||||
t.Fatalf("groupPoints(start=%d, size=%d) = %v,期望 %v", tc.start, tc.size, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 端到端对照:同样的并列,走完整算分后落到名次明细上的分数必须与上面一致。
|
||||
t.Run("端到端:两人并列第6 的 average 口径", func(t *testing.T) {
|
||||
entries, results := fixture(lineup(7))
|
||||
// 第 6、7 名同分(都记 6.0)→ 并列占第 6、7 名;第 7 名没有配置分。
|
||||
results[5].ResultValue = ptr(6.0)
|
||||
results[6].ResultValue = ptr(6.0)
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{athleteResultChannel(9)},
|
||||
Profiles: map[int64]ScoringProfile{9: {AwardRanks: 6, TieMode: TieAverage, Rules: individualTop6().Rules}},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
points := pointsByEntry(es.Ledger)
|
||||
if !nearlyEqual(points[6], 0.5) || !nearlyEqual(points[7], 0.5) {
|
||||
t.Fatalf("并列第 6 的两人应各得 0.5 分,得到 %v / %v", points[6], points[7])
|
||||
}
|
||||
if !nearlyEqual(points[5], 2) {
|
||||
t.Fatalf("第 5 名应得 2 分,得到 %v", points[5])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,7 @@ type StandingsInput struct {
|
||||
TieBreak []string
|
||||
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
|
||||
IndividualToTeamWeight float64
|
||||
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
|
||||
// ExcludeTeamEventMedals 为 true 时单位奖牌榜不计团体项目奖牌;默认计入。
|
||||
ExcludeTeamEventMedals bool
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
// BuildMedalStandings 生成团体奖牌榜。
|
||||
// BuildMedalStandings 生成单位奖牌榜。
|
||||
// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。
|
||||
func BuildMedalStandings(in StandingsInput) []MedalRow {
|
||||
teamRows := BuildTeamStandings(in)
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestMedalStandings(t *testing.T) {
|
||||
rows = BuildMedalStandings(in)
|
||||
for _, r := range rows {
|
||||
if r.TeamID == 1 && r.Gold != 0 {
|
||||
t.Fatalf("排除团体奖牌后单位1金牌 = %d,期望 0", r.Gold)
|
||||
t.Fatalf("排除团体项目奖牌后单位1金牌 = %d,期望 0", r.Gold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ type StandingsRow struct {
|
||||
Medals int `json:"medals"`
|
||||
}
|
||||
|
||||
// MedalRow 团体奖牌榜的一行。
|
||||
// MedalRow 单位奖牌榜的一行。
|
||||
type MedalRow struct {
|
||||
Rank int `json:"rank"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
|
||||
Reference in new issue
Block a user