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

254 lines
10 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 (
"database/sql"
"errors"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// waitDisplayAudit 取最后一条 display.command 审计的 after_json。
// 审计中间件在响应写完之后才落库,所以这里给一小段重试窗口。
func waitDisplayAudit(t *testing.T, env *testEnv) string {
t.Helper()
for i := 0; i < 100; i++ {
var after sql.NullString
err := env.ev.QueryRow(
`SELECT after_json FROM audit_logs WHERE action = 'display.command' ORDER BY id DESC LIMIT 1`).Scan(&after)
switch {
case err == nil:
return after.String
case errors.Is(err, sql.ErrNoRows):
time.Sleep(10 * time.Millisecond)
default:
t.Fatalf("查询审计: %v", err)
}
}
t.Fatal("等不到 display.command 的审计记录")
return ""
}
// settingOrEmpty 读 settings 表里的点号键,缺行返回空串(没写过公告时就没有这一行)。
func settingOrEmpty(t *testing.T, env *testEnv, key string) string {
t.Helper()
var v sql.NullString
err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
switch {
case err == nil:
return v.String
case errors.Is(err, sql.ErrNoRows):
return ""
default:
t.Fatalf("读取配置 %s: %v", key, err)
}
return ""
}
// newDisplayEnv 建一个带 recorder 客户端与指令桩的环境。
func newDisplayEnv(t *testing.T, sender *strictDisplaySender) (*testEnv, *http.Client) {
t.Helper()
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender })
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
return env, client
}
// 带 targets 的指令只发给点名的实例,命中数与目标一并进审计。
func TestDisplayCommandTargetsInstances(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "goto",
"payload": map[string]any{"page": "medals"},
"targets": []string{"screen-a", "screen-b"},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("定向下发应 200,得到 %d %+v", status, env2.Error)
}
if len(sender.targets) != 1 || len(sender.targets[0]) != 2 ||
sender.targets[0][0] != "screen-a" || sender.targets[0][1] != "screen-b" {
t.Fatalf("targets 应原样传给实时层,得到 %+v", sender.targets)
}
after := waitDisplayAudit(t, env)
for _, want := range []string{`"action":"goto"`, `"targets":["screen-a","screen-b"]`, `"matched":2`} {
if !strings.Contains(after, want) {
t.Fatalf("审计应含 %s,得到 %s", want, after)
}
}
}
// 不带 targets = 广播:实时层收到空 targets,公告这条链路照旧。
func TestDisplayCommandBroadcastKeepsWorking(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "announce",
"payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("广播公告应 200,得到 %d %+v", status, env2.Error)
}
if len(sender.targets) != 1 || sender.targets[0] != nil {
t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets)
}
if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" {
t.Fatalf("广播公告仍应落库,得到 %q", got)
}
if after := waitDisplayAudit(t, env); !strings.Contains(after, `"matched":1`) {
t.Fatalf("广播也要记命中数,得到 %s", after)
}
}
// 目标全部离线:返回可读的 404,且公告不落库、不产生新的 settings 变更。
func TestDisplayCommandOfflineTargetReturns404(t *testing.T) {
matched := 0
sender := &strictDisplaySender{matched: &matched}
env, client := newDisplayEnv(t, sender)
status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "announce",
"payload": map[string]any{"text": "这条不该落库", "seconds": 30},
"targets": []string{"gone-screen"},
}, true)
if status != http.StatusNotFound || env2.Error == nil {
t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error)
}
if env2.Error.Message != "目标大屏已离线,请刷新在线列表" {
t.Fatalf("404 消息应为可读中文,得到 %q", env2.Error.Message)
}
if got := settingOrEmpty(t, env, "display.announce_text"); got != "" {
t.Fatalf("没命中任何屏时公告不该落库,得到 %q", got)
}
// 定向失败的请求没有成功审计(审计只记 2xx)。
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'display.command'`).Scan(&n); err != nil {
t.Fatalf("统计审计: %v", err)
}
if n != 0 {
t.Fatalf("失败的指令不该留 display.command 审计,得到 %d 条", n)
}
}
// GET /api/display/instances 要把屏号带出来,方便面板把本机显示器与在线实例对上。
func TestDisplayInstancesExposeScreen(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
fake.instances = []realtime.Instance{
{ID: "console-1", Online: true, Page: "standings", Screen: "2", RemoteAddr: "192.168.1.9:51000"},
{ID: "lan-1", Online: true, Page: "medals"},
}
status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error)
}
rows := decodeData[[]map[string]any](t, env2)
if len(rows) != 2 {
t.Fatalf("应返回 2 个实例,得到 %+v", rows)
}
if rows[0]["screen"] != "2" || rows[1]["screen"] != "" {
t.Fatalf("屏号字段应原样带出(无屏号为空串),得到 %+v", rows)
}
if rows[0]["page"] != "standings" || rows[1]["page"] != "medals" {
t.Fatalf("当前页字段应原样带出,得到 %+v", rows)
}
}
// 权限:指令与实例列表都要求 recorder 及以上,guest 一律拒绝。
func TestDisplayEndpointsRequireRecorder(t *testing.T) {
sender := &strictDisplaySender{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender })
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/display/command",
map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, false); status == http.StatusOK {
t.Fatalf("guest 下发指令不该成功,得到 %d", status)
}
if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读实例列表应 403,得到 %d", status)
}
env, client := newDisplayEnv(t, sender)
if status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "goto", "payload": map[string]any{"page": "medals"},
}, true); status != http.StatusOK {
t.Fatalf("recorder 下发指令应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读实例列表应 200,得到 %d %+v", status, env2.Error)
}
}
// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验),
// 但它是请求体入参不合法:按 400 报,现场才看得懂要改 action 而不是「服务端异常」。
func TestDisplayCommandRejectsUnknownAction(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "nope"}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("未知 action 应被拒收为 400,得到 %d %+v", status, env2.Error)
}
if env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("未知 action 的错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error)
}
// message 保持实时层那句中文原文(现场要看懂),detail 带上原始 action(便于检索)。
if !strings.Contains(env2.Error.Message, "未知的大屏指令") {
t.Fatalf("message 应保持可读的中文原文,得到 %q", env2.Error.Message)
}
if got := string(env2.Error.Detail); got != `"nope"` {
t.Fatalf("detail 应带上原始 action,得到 %s", got)
}
if len(sender.sent) != 0 {
t.Fatalf("未知 action 不该下发,得到 %v", sender.sent)
}
}
// C27:announce_clear 与其它指令同路(realtime.CmdAnnounceClear 已在协议清单里),
// 不再需要「转发前先问实时层认不认」的特判。这条用例把两件事一起钉住:
// 指令要下发(否则大屏挂着旧公告),持久值也要清空(否则大屏重连又把它显示出来)。
func TestDisplayCommandAnnounceClearForwardsAndClears(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
if status, e := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "announce",
"payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30},
}, true); status != http.StatusOK {
t.Fatalf("先发一条公告应 200,得到 %d %+v", status, e.Error)
}
if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" {
t.Fatalf("公告应已落库,得到 %q", got)
}
sender.sent = nil
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "announce_clear"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清空公告应 200,得到 %d %+v", status, env2.Error)
}
if len(sender.sent) != 1 || sender.sent[0] != "announce_clear" {
t.Fatalf("清空公告必须下发给实时层,得到 %+v", sender.sent)
}
if got := settingValue(t, env, "display.announce_text"); got != "" {
t.Fatalf("清空公告应把持久值写成空串,得到 %q", got)
}
if after := waitDisplayAudit(t, env); !strings.Contains(after, `"action":"announce_clear"`) {
t.Fatalf("清空公告也要留 display.command 审计,得到 %s", after)
}
}