Files
AskaEth 6841cf49aa 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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

403 lines
13 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 rules
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 奖牌结算三个开关(devdocs 17 §三 / §六):
// - Channel.countToMedals / countToTeamMedals(缺省 true)
// - EventOverride.awardMedals(三态,nil = 不动该字段)
// 这里覆盖解析、落库、回读、差异比对四条路,保证「导出 → 导入」幂等。
const medalFlagsPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p1, countToTeam: false, countToMedals: false, countToTeamMedals: false }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: p1 }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: p1 }
`
func TestParseMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0]
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("countToMedals: false 应落到结构体,实际 %+v", ch.CountToMedals)
}
if ch.CountsToMedals() {
t.Error("countToMedals: false 时 CountsToMedals() 应为 false")
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("countToTeamMedals: false 应落到结构体,实际 %+v", ch.CountToTeamMedals)
}
if ch.CountsToTeamMedals() {
t.Error("countToTeamMedals: false 时 CountsToTeamMedals() 应为 false")
}
ind := pack.EventOverrides[0]
if ind.AwardMedals == nil || *ind.AwardMedals {
t.Fatalf("awardMedals: false 应落到结构体,实际 %+v", ind.AwardMedals)
}
if pack.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写 awardMedals 时应保持 nil,实际 %v", *pack.EventOverrides[1].AwardMedals)
}
// 缺省即 true:不写三个键的通道既要保持 nil(不凭空造显式值),又要按 true 生效。
def := pack.EventOverrides[1].Channels
if def == nil || len(*def) != 1 {
t.Fatalf("EVT_TEAM 通道解析错误: %+v", def)
}
if (*def)[0].CountToMedals != nil || (*def)[0].CountToTeamMedals != nil {
t.Errorf("未写奖牌开关时应保持 nil,实际 %+v", (*def)[0])
}
if !(*def)[0].CountsToMedals() || !(*def)[0].CountsToTeamMedals() {
t.Error("未写奖牌开关时应按缺省 true 生效")
}
// 序列化后再解析必须完全一致(三个键要能出现在导出文件里)。
reparsed, err := Parse(mustMarshal(t, pack))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
if !channelsEqual(pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels) {
t.Errorf("模板通道往返不一致:\n%+v\n%+v", pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels)
}
again := reparsed.EventOverrides[0]
if again.AwardMedals == nil || *again.AwardMedals {
t.Errorf("awardMedals 往返不一致: %+v", again.AwardMedals)
}
if reparsed.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写的 awardMedals 往返后应保持 nil,实际 %v", *reparsed.EventOverrides[1].AwardMedals)
}
}
func TestChannelToDomainCarriesMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("ToDomain 应带上 countToMedals: false,实际 %+v", ch.CountToMedals)
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("ToDomain 应带上 countToTeamMedals: false,实际 %+v", ch.CountToTeamMedals)
}
// 缺省的通道不落指针,与 countToTeam 同口径。
def := (*pack.EventOverrides[1].Channels)[0].ToDomain(3, 7, 0)
if def.CountToMedals != nil || def.CountToTeamMedals != nil {
t.Errorf("缺省通道不应写显式值: %+v", def)
}
}
// 库里 award_medals=0、别的都缺省的项目,导出时必须写成只带 awardMedals 的 override,
// 否则「导出 → 导入」会把关闭的奖牌开关丢掉。
func TestLoadExportsAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_IND'`); err != nil {
t.Fatalf("预置奖牌开关: %v", err)
}
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
var ov *EventOverride
for i := range snap.Pack.EventOverrides {
if snap.Pack.EventOverrides[i].EventCode == "EVT_IND" {
ov = &snap.Pack.EventOverrides[i]
}
}
if ov == nil {
t.Fatalf("导出应包含 EVT_IND 的 override: %+v", snap.Pack.EventOverrides)
}
if ov.AwardMedals == nil || *ov.AwardMedals {
t.Fatalf("EVT_IND 应导出 awardMedals: false,实际 %+v", ov.AwardMedals)
}
if ov.Scored != nil || ov.Multiplier != nil || ov.Channels != nil {
t.Errorf("其余字段保持缺省,不该被一起写出来: %+v", ov)
}
// 导出结果重新导入必须无差异。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 落库 → 直接查列 → Load 回读,断言与输入一致,并且导出再导入无差异。
func TestApplyMedalFlagsThenLoadRoundTrip(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 10 }
- code: team_top6
name: 团体项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 15 }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToMedals: false, countToTeamMedals: false }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: team_top6, countToMedals: false }
`
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
// 小项级开关。
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 1 {
t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got)
}
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、并入单位榜照旧。
wants := map[string][3]int{
"EVT_IND": {1, 0, 0},
"EVT_TEAM": {1, 0, 1},
}
rows, err := db.Query(`SELECT e.code, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN events e ON e.id = c.event_id ORDER BY e.code, c.sort_order, c.id`)
if err != nil {
t.Fatalf("查询通道: %v", err)
}
defer func() { _ = rows.Close() }()
seen := map[string]int{}
for rows.Next() {
var code string
var countToTeam, countToMedals, countToTeamMedals int
if err := rows.Scan(&code, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("扫描通道: %v", err)
}
want, ok := wants[code]
if !ok {
t.Fatalf("出现预期外的通道: %s", code)
}
got := [3]int{countToTeam, countToMedals, countToTeamMedals}
if got != want {
t.Fatalf("%s 通道开关 = %v,期望 %v", code, got, want)
}
seen[code]++
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历通道: %v", err)
}
if seen["EVT_IND"] != 1 || seen["EVT_TEAM"] != 1 {
t.Fatalf("通道数异常: %v", seen)
}
// Load 回读:三个字段都要还原成「显式 false」而不是缺省 true。
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
ind, ok := snap.EventByCode("EVT_IND")
if !ok {
t.Fatal("Load 结果里缺少 EVT_IND")
}
if ind.AwardMedals {
t.Error("Load 应读出 EVT_IND award_medals = 0")
}
if len(ind.Channels) != 1 {
t.Fatalf("EVT_IND 通道数 = %d,期望 1", len(ind.Channels))
}
if ind.Channels[0].CountsToMedals() || ind.Channels[0].CountsToTeamMedals() {
t.Errorf("EVT_IND 通道应按显式 false 读回: %+v", ind.Channels[0])
}
team, ok := snap.EventByCode("EVT_TEAM")
if !ok {
t.Fatal("Load 结果里缺少 EVT_TEAM")
}
if !team.AwardMedals {
t.Error("Load 应读出 EVT_TEAM award_medals = 1")
}
if team.Channels[0].CountsToMedals() {
t.Error("EVT_TEAM 通道 count_to_medals 应为 false")
}
if !team.Channels[0].CountsToTeamMedals() {
t.Error("EVT_TEAM 未关闭 count_to_team_medals,应保持 true")
}
// 导出再导入必须无差异:显式 false 与缺省 true 不能被读串。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 只写 awardMedals 的 EventOverride 也要生效:判空若只看 scored/multiplier,这条会被整条跳过。
func TestApplyAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 只写奖牌开关 }
profiles:
- { code: individual_top6, name: 个人项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 10 } ] }
- { code: team_top6, name: 团体项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 15 } ] }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
- eventCode: EVT_TEAM
multiplier: 2
`
// 先把 EVT_TEAM 的奖牌开关也关掉,验证「override 不提该字段就不动它」。
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_TEAM'`); err != nil {
t.Fatalf("预置 EVT_TEAM 开关: %v", err)
}
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res, err := Apply(db, &ChangeSet{Next: next})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.EventsUpdated != 2 {
t.Errorf("项目更新数 = %d,期望 2(只写 awardMedals 的也要落库)", res.EventsUpdated)
}
if res.ChannelsWritten != 0 {
t.Errorf("没有模板/通道配置,不该重建通道,实际 %d", res.ChannelsWritten)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 0 {
t.Errorf("override 未写 awardMedals 时不应改动该列,实际 %d", got)
}
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_TEAM'`); got != 2 {
t.Errorf("EVT_TEAM 权重 = %v,期望 2", got)
}
// 种子里的通道既没被删也没被改。
if got := countRows(t, db, "event_score_channels"); got != 1 {
t.Errorf("通道数 = %d,期望 1(未被规则包提到)", got)
}
}
// 通道只差奖牌开关也必须报差异,否则前端预览看不到、用户以为没改动。
func TestDiffDetectsMedalFlagChanges(t *testing.T) {
base := Channel{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}
cases := []struct {
name string
mut func(Channel) Channel
}{
{"countToMedals", func(c Channel) Channel { c.CountToMedals = BoolPtr(false); return c }},
{"countToTeamMedals", func(c Channel) Channel { c.CountToTeamMedals = BoolPtr(false); return c }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{tc.mut(base)}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 1 || cs.Channels[0].EventCode != "EVT_IND" {
t.Fatalf("应报出 EVT_IND 的通道变化,实际 %+v", cs.Channels)
}
if cs.Channels[0].Kind != ChangeModified {
t.Errorf("变化类型 = %q,期望 modified", cs.Channels[0].Kind)
}
})
}
t.Run("缺省与显式 true 不算变化", func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{{
Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6",
CountToMedals: BoolPtr(true), CountToTeamMedals: BoolPtr(true),
}}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 0 {
t.Fatalf("显式 true 与缺省同义,不应报差异: %+v", cs.Channels)
}
})
}