fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)

## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+225 -32
View File
@@ -26,6 +26,17 @@ const (
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
//
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
// 客户端按自己的节奏回来时(≥ 它的上次发送 + 3s),服务端的窗口必须已经过期,
// 否则两个等长的窗口会因收发延迟抖动而被随机判定成「超频」,把正常的补齐请求挡掉。
// 反过来,2.5s 也已经把放大面压到「每个连接每 2.5 秒最多一份整库快照」
// (一份 = 一个事务 + 8 条查询,赛事库还是单连接)。
defaultSnapshotMinInterval = 2500 * time.Millisecond
// sessionCheckTimeout 是会话复查查询的超时:系统库同样只有一条连接,
// 被长操作占住时不能把心跳循环一起卡住。
sessionCheckTimeout = 2 * time.Second
)
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
@@ -63,6 +74,16 @@ type Hub struct {
helloTO time.Duration
writeTO time.Duration
allowedOrigins []string
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
snapshotMinInterval time.Duration
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
sessionChecker SessionChecker
}
// SessionChecker 判定一条会话当前是否仍然有效(未被撤销 / 未过期 / 用户未停用)。
// auth.Service 满足这个接口;测试可注入桩实现。
type SessionChecker interface {
SessionActive(ctx context.Context, sessionID string) (bool, error)
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
@@ -73,19 +94,20 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub {
}
ctx, cancel := context.WithCancel(context.Background())
h := &Hub{
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
snapshotMinInterval: defaultSnapshotMinInterval,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
@@ -177,6 +199,38 @@ func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Unlock()
}
// SetSessionChecker 注入会话复查器:连接建立后周期性复查会话是否仍然有效,失效即断开。
//
// /ws 的身份在握手时固化,而会话随时可能被撤销(登出、改密、管理员重置密码、停用用户)。
// 不复查的话,那条长连接会一直以原角色收推送——被停用的人只要不关页面,管理端就还挂着。
func (h *Hub) SetSessionChecker(c SessionChecker) {
h.mu.Lock()
h.sessionChecker = c
h.mu.Unlock()
}
// SetSnapshotMinInterval 设置同一连接两次整库快照之间的最小间隔;<= 0 表示不限流(仅测试用)。
//
// 撞到窗口的请求不会拿到第二份全量:客户端本来就刚拿到过一份,心跳里的版本号会让它
// 在需要时重新发起;越过窗口后一切照旧。
func (h *Hub) SetSnapshotMinInterval(d time.Duration) {
h.mu.Lock()
h.snapshotMinInterval = d
h.mu.Unlock()
}
func (h *Hub) sessionCheckerValue() SessionChecker {
h.mu.RLock()
defer h.mu.RUnlock()
return h.sessionChecker
}
func (h *Hub) snapshotMinIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.snapshotMinInterval
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -237,26 +291,45 @@ func (h *Hub) Handler() http.Handler {
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
role, userID, settingsAdmin := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
h.serve(conn, r.RemoteAddr, wsIdentityFromContext(r.Context()))
})
}
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentity(ctx context.Context) (string, int64, bool) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
return RoleDisplay, 0, false
}
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
// wsIdentity 是握手时从会话取到的身份;整条连接的生命周期都用它。
//
// sessionID 不进协议,只为会话复查保留:会话被撤销后要据此找到并断开对应的连接。
type wsIdentity struct {
Role string
UserID int64
SessionID string
SettingsAdmin bool
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
// wsIdentityFromContext 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentityFromContext(ctx context.Context) wsIdentity {
id := wsIdentity{}
if sess := middleware.Session(ctx); sess != nil {
id.SessionID = sess.ID
}
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
// 会话无效时 Authenticate 已经降级成 guest,这里拿不到会话 id,
// 因此降级后的连接不会被会话复查误伤。
id.Role = RoleDisplay
return id
}
id.Role = RoleAdmin
id.UserID = u.ID
id.SettingsAdmin = u.Role.Allows(auth.RoleAdmin)
return id
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
c := newClient(h, conn, remoteAddr, id)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
@@ -402,6 +475,8 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
// sendInitial 决定首包是 snapshot 还是 catchup:
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
//
// 两个入口共用这一条路径:hello(首包 / 翻页补的那条)与 resync。
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.sendMu.Lock()
defer h.sendMu.Unlock()
@@ -419,34 +494,91 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.setVersion(cur)
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
n, err := h.countChangesSince(ctx, *lastVersion)
if err != nil {
h.log.Warn("统计增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if n > h.maxCatchupValue() {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
changes, err := h.changesSince(ctx, *lastVersion, 0)
if err != nil {
h.log.Warn("读取增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) {
// catchup 与 change 一样按连接身份裁剪:重连补齐不能成为读系统配置的后门。
payload := catchupPayload{
FromVersion: *lastVersion,
Version: cur,
Changes: redactChanges(changes, c.settingsAdmin),
}
if !c.sendEvent(EventCatchup, payload) {
return
}
c.lastSent.Store(cur)
c.ackVersion.Store(cur)
}
// sendSnapshotGuarded 是「要发整库快照」的唯一出口(调用方须持有 sendMu,同 sendSnapshotLocked)。
//
// 整库快照的代价是一个 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 读取还在全局发送锁里:不限流的话,一条 15 字节的帧(resync,或重复的不带 lastVersion 的
// hello)就能反复触发,把库连接与所有人的推送一起堵住。这里按连接记账,窗口内已经发过
// 整库快照就不再发第二份。
//
// 只拦「整库快照」这一种:带 lastVersion 的 hello 走 catchup(增量)不受影响;
// 每次断线重连都是新连接(新的 client,窗口为空),第一份快照照发。
//
// 被挡下的请求不丢弃,只在连接上挂一个标记,由心跳循环补发(见 flushPendingSnapshot):
// 请求方拿到的是「稍微晚一点的一份」,而不是「什么都没有」。
func (h *Hub) sendSnapshotGuarded(ctx context.Context, c *client) bool {
interval := h.snapshotMinIntervalValue()
if !c.allowSnapshot(interval) {
c.snapshotPending.Store(true)
h.log.Debug("整库快照窗口内,改为心跳补发", "remote", c.remoteAddr, "interval", interval.String())
return false
}
// 这一份已经满足了挂起的请求(否则它会在下个心跳里变成多余的第三份)。
c.snapshotPending.Store(false)
h.sendSnapshotLocked(ctx, c)
return true
}
// flushPendingSnapshot 在心跳里补发被窗口挡下的整库快照;返回 true 表示补发了一份。
//
// 规则:只有在「有待发标记」且「窗口确实已过」时才发,发完清标记——
// 不能做成每次心跳都发一份(那等于把限流从 2.5s 放大成心跳频率)。
// 最迟一个心跳周期(默认 10s)内一定补上,正常客户端感知不到差别。
func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
if !c.snapshotPending.Load() {
return false
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
// 加锁后再看一次:期间可能有别的路径已经发过(那时标记已被清掉)。
if !c.snapshotPending.Load() {
return false
}
if !c.allowSnapshot(h.snapshotMinIntervalValue()) {
// 窗口还没过(刚有别的请求发过一份):留给下一个心跳。
return false
}
c.snapshotPending.Store(false)
h.log.Debug("心跳补发挂起的整库快照", "remote", c.remoteAddr)
h.sendSnapshotLocked(ctx, c)
return true
}
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
// 必须原子完成,否则并发的 change 会插到快照前面。
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
@@ -503,12 +635,13 @@ func (h *Hub) fanout(room string, changes []Change) {
}
last := c.lastSent.Load()
settingsAdmin := c.settingsAdmin
filtered := make([]Change, 0, len(changes))
for _, ch := range changes {
if ch.Version > 0 && ch.Version <= last {
continue
}
filtered = append(filtered, ch)
filtered = append(filtered, redactChange(ch, settingsAdmin))
}
if len(filtered) == 0 {
continue
@@ -671,6 +804,9 @@ func (h *Hub) beat() {
now := time.Now()
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
// 挂起补发单独收集,等心跳全部发完再做:补发要现建一份快照(慢,且要抢 sendMu),
// 不能让排在后面的大屏连心跳都跟着等(落线判定的看门狗就在客户端那边盯着心跳)。
var pending []*client
for _, c := range h.roomClients("") {
c.mu.Lock()
greeted := c.greeted
@@ -678,8 +814,65 @@ func (h *Hub) beat() {
if !greeted {
continue
}
// 会话失效(撤销 / 过期 / 用户停用)的连接先断开,不再给它下发心跳。
if h.sessionRevoked(c) {
continue
}
c.sendEvent(EventHeartbeat, data)
h.touchInstance(c, now)
h.checkSlow(c, version)
if c.snapshotPending.Load() {
pending = append(pending, c)
}
}
for _, c := range pending {
h.flushPendingSnapshot(h.ctx, c)
}
}
// sessionRevoked 复查连接背后的会话是否仍然有效,失效即发起断开;返回 true 表示已断开。
//
// 会话撤销(登出 / 改密 / 管理员重置密码 / 停用用户)会删掉会话行或停用用户,
// 但 /ws 的身份在握手时就固化了:不复查的话,那条长连接会一直以原角色收推送,
// 只有写操作被 HTTP 侧拦住。复查挂在心跳上,最迟一个心跳周期(默认 10s)内断开。
//
// 只有带会话的连接需要复查:guest 大屏连接没有会话,撤销语义不适用。
// 查询失败(库忙 / 系统库被长操作占住)一律按「仍有效」处理并留痕——
// 一次查询抖动不该把正在比赛的大屏整片踢下线。
func (h *Hub) sessionRevoked(c *client) bool {
checker := h.sessionCheckerValue()
if checker == nil {
return false
}
c.mu.Lock()
sessionID, role := c.sessionID, c.role
c.mu.Unlock()
if sessionID == "" {
return false
}
ctx, cancel := context.WithTimeout(h.ctx, sessionCheckTimeout)
defer cancel()
active, err := checker.SessionActive(ctx, sessionID)
if err != nil {
h.log.Warn("复查会话失败,本次保留连接", "session", sessionID, "remote", c.remoteAddr, "err", err)
return false
}
if active {
return false
}
// 管理端连接按「登录失效」关闭:前端对所有 1008 会停止重连并引导重新登录,
// 这正是被撤销的会话应有的表现。大屏连接改用 1001 让它自动重连——
// 重连时握手会按会话失效降级成 guest(HTTP 侧本来也是这样),
// 现场大屏不该因为操作员登出而永久冻结。
code, reason := websocket.StatusGoingAway, "会话已失效,按游客身份重连"
if role == RoleAdmin {
code, reason = websocket.StatusPolicyViolation, "会话已失效,请重新登录"
}
h.log.Info("会话已失效,断开实时连接", "session", sessionID, "role", role, "remote", c.remoteAddr)
c.stop(code, reason)
return true
}