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

183 lines
6.0 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 realtime
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。
//
// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到变量,只能按原文认。
// httpapi 包里有一份同口径的实现(那一份也导不出来,实时层又依赖不了 httpapi——会成环),
// 两份必须保持同一套语义:库已关闭 = 恢复/重启动作的中间状态,不是故障。
var errDBClosed = errors.New("sql: database is closed")
// dbClosed 判定错误是否只是「库已经被关掉」。
//
// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:别的读库失败(锁超时、
// SQL 写错、库损坏)都得继续按 WARN/ERROR 报出来,不能被这条静默规则顺手吞掉。
func dbClosed(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if e.Error() == errDBClosed.Error() {
return true
}
}
return false
}
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
// NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。
func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
if h.db == nil {
return h.Version(), nil
}
var v sql.NullInt64
if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
return 0, fmt.Errorf("读取 change_log 版本号: %w", err)
}
if !v.Valid {
return 0, nil
}
return v.Int64, nil
}
// currentVersionCached 在数据库不可用时退回内存里的版本,保证心跳与广播不会因一次查询失败而中断。
// 「库已关闭」(恢复/重启窗口)只记 Debug:那不是故障,而且它每个心跳周期都会发生一次,
// 记 WARN 会让现场日志刷成一片红。
func (h *Hub) currentVersionCached(ctx context.Context) int64 {
if h.db == nil {
return h.Version()
}
v, err := h.currentVersion(ctx)
if err != nil {
if dbClosed(err) {
h.log.Debug("读取版本号:库已关闭(恢复/重启中),沿用内存版本", "version", h.Version())
return h.Version()
}
h.log.Warn("读取版本号失败,沿用内存版本", "err", err, "version", h.Version())
return h.Version()
}
h.setVersion(v)
return v
}
func (h *Hub) countChangesSince(ctx context.Context, from int64) (int, error) {
var n int
err := h.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM change_log WHERE id > ?`, from).Scan(&n)
if err != nil {
return 0, fmt.Errorf("统计增量变更: %w", err)
}
return n, nil
}
// changesSince 按版本升序取增量;limit <= 0 表示不限量。
func (h *Hub) changesSince(ctx context.Context, from int64, limit int) ([]Change, error) {
q := `SELECT id, entity_type, entity_id, action, payload_json
FROM change_log WHERE id > ? ORDER BY id`
args := []any{from}
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("读取增量变更: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Change, 0, 16)
for rows.Next() {
var (
ch Change
entity string
entityID sql.NullInt64
action string
payload string
)
if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil {
return nil, fmt.Errorf("解析变更行: %w", err)
}
ch.Entity = entity
ch.ID = entityID.Int64
ch.Action = action
ch.Data = payloadJSON(payload)
out = append(out, ch)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历变更行: %w", err)
}
return out, nil
}
// LoadChanges 供写操作完成后读取「自 sinceVersion 起的变更」,再交给 Broadcast 推送。
func (h *Hub) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]Change, error) {
if h.db == nil {
return nil, nil
}
return h.changesSince(ctx, sinceVersion, limit)
}
// payloadJSON 保证 data 永远是合法 JSON:change_log 允许写入任意文本,
// 空串或坏 JSON 一律降级成空对象,避免把坏数据传播给客户端。
func payloadJSON(payload string) json.RawMessage {
if payload == "" {
return json.RawMessage(`{}`)
}
if !json.Valid([]byte(payload)) {
quoted, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return quoted
}
return json.RawMessage(payload)
}
// entitySettings 是赛事配置在 change_log.entity_type 里的取值;
// 它的载荷是完整的 dto.EventSettings,含只有 admin 能读的系统配置字段。
const entitySettings = "settings"
// redactChange 按连接身份裁剪一条变更的载荷,返回不与其它连接共享的新对象。
//
// 快照对非 admin 会套 WithoutAdminFields 砍掉 defaultProfileId / requireApproval /
// individualToTeamWeight,但 change 与 catchup 走的是另一条路径:不在这里做同样处理,
// 任何打开 /ws 的浏览器都能从增量里读到这三个字段,快照上的裁剪等于被旁路。
// 只动 settings 这一个实体,其余实体的载荷本来就是公开的赛事数据。
func redactChange(ch Change, settingsAdmin bool) Change {
if settingsAdmin || ch.Entity != entitySettings {
return ch
}
var settings dto.EventSettings
if err := json.Unmarshal(ch.Data, &settings); err != nil {
// 载荷不是 EventSettings 形状(历史数据 / 手工改库):宁可只下发空对象,
// 也不能把可能含系统配置的原文透给非 admin。
ch.Data = json.RawMessage(`{}`)
return ch
}
data, err := json.Marshal(settings.WithoutAdminFields())
if err != nil {
ch.Data = json.RawMessage(`{}`)
return ch
}
ch.Data = data
return ch
}
// redactChanges 批量裁剪;admin 连接原样返回,不做无谓的复制。
func redactChanges(changes []Change, settingsAdmin bool) []Change {
if settingsAdmin {
return changes
}
out := make([]Change, 0, len(changes))
for _, ch := range changes {
out = append(out, redactChange(ch, settingsAdmin))
}
return out
}