Files
Scoreboard/server/internal/revisions/trail.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

212 lines
8.6 KiB
Go
Raw 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 revisions 从 result_revisions 的版本链里还原「这条成绩被改过几次、最后一次因为什么改」。
//
// 两个消费方:单位积分明细的屏幕展示(httpapi.teamBreakdown)与 xlsx 导出(export.loadTeamDetailRows)。
// 口径必须同源 —— 屏幕上写着「已修订 ×2」而导出件写「已修订 ×1」,就是同一份数据两处打架,
// 所以查询与判定都收在这里,调用方只负责排版。
//
// 判定「被改过」的判据(保守优先,宁可不显示数字也不误报):
// - 身份用 (event_id, entry_id):results.entry_id 唯一,且「退回草稿 / 作废成绩」只改这一行的
// 状态、不换报名项,重新发布也只会作废旧流水、写新流水。所以按这条线数历史版本,
// 不会被「重新发布产生新流水行」打断(ledger_ids 写入侧早已不写,result_id 也会随发布变化)。
// - 只比「录进去的成绩」本身:成绩文本 / 数值 / 单位 / 并列 / 状态。
// 刻意不比 rank 与 state:名次是引擎每次发布按当前成绩重算的(别人改了成绩,我的名次也会变),
// state 是发布动作自己写的,两者都会在「这条成绩一个字没改」的情况下逐次发布都不同。
// - 至少要见过两次快照才可能报数:第一次出现是基线,不计;一次都没出现过(例如升级前就发布、
// 没有版本记录)则整条不返回,调用方据此显示为空。
package revisions
import (
"context"
"database/sql"
"strings"
)
// Key 是一条成绩在版本链里的身份:同一个小项里的同一个报名项。
type Key struct {
EventID int64
EntryID int64
}
// KeyFor 返回一条流水在版本链里的身份;返回零值表示这条流水追不了修订痕迹(调用方按空显示)。
//
// 只认**成绩流水本身**(source_type = 'result'):破纪录加分与人工调整是站在成绩旁边的另一笔分,
// 把成绩的修订次数挂到它们头上,等于对外说「那笔加分被改过」—— 那不是事实,正是要避免的误报。
// 聚合流水(按队内平均)与团体手工名次没有单个报名项,同样返回零值。
func KeyFor(sourceType string, eventID, entryID int64) Key {
if sourceType != "result" || eventID == 0 || entryID == 0 {
return Key{}
}
return Key{EventID: eventID, EntryID: entryID}
}
// Trail 是一条成绩的修订痕迹。Count == 0 表示追溯成功但没有改动过(调用方不应显示角标)。
type Trail struct {
Count int
// At 是最后一次改动落库的时间,SQLite datetime('now') 的原样字符串(UTC,不带时区)。
At string
// Reason 是最后一次改动的原因;确实没记录过就是空串 —— 宁可不写也不编。
Reason string
// ActorID 是最后一次改动的操作人(指向系统库 users,跨库不能建外键,解析姓名由调用方负责)。
ActorID *int64
}
// Queryer 是 *sql.DB / *sql.Tx 的最小公共面。
type Queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// snapshotColumns 从快照里的每条成绩对象上取出的字段。
//
// 取的就是 httpapi 的 resultDTO 序列化后的 JSON 键(该类型不导出,改字段名要同步这里)。
// 只取判定用得到的几项:rank / state / verifiedBy 之类故意不取,见包注释。
const snapshotColumns = `json_extract(v.value, '$.entryId'),
COALESCE(json_extract(v.value, '$.resultText'), ''),
json_extract(v.value, '$.resultValue'),
COALESCE(json_extract(v.value, '$.unit'), ''),
COALESCE(json_extract(v.value, '$.isTie'), 0),
COALESCE(json_extract(v.value, '$.status'), ''),
COALESCE(json_extract(v.value, '$.note'), '')`
// chunk 是一次 IN 查询里最多带几个 Key:事件与报名项各一个 IN 列表,参数个数翻倍,
// SQLite 的宿主参数上限(默认 32766)不该被一次明细请求摸到。
const chunk = 100
// Load 汇总这些成绩的修订痕迹。返回的 map 里只包含「在版本链里出现过」的 Key。
//
// 读不出来(快照损坏等)时返回错误,由调用方决定降级:明细是展示层的东西,
// 不该因为追溯不出来就整个接口失败。
func Load(ctx context.Context, q Queryer, keys []Key) (map[Key]Trail, error) {
out := make(map[Key]Trail)
seen := make(map[Key]bool)
unique := make([]Key, 0, len(keys))
for _, k := range keys {
if k.EventID == 0 || k.EntryID == 0 || seen[k] {
continue
}
seen[k] = true
unique = append(unique, k)
}
for start := 0; start < len(unique); start += chunk {
end := start + chunk
if end > len(unique) {
end = len(unique)
}
batch := unique[start:end]
eventIDs := make([]any, 0, len(batch))
entryIDs := make([]any, 0, len(batch))
wanted := make(map[Key]bool, len(batch))
for _, k := range batch {
eventIDs = append(eventIDs, k.EventID)
entryIDs = append(entryIDs, k.EntryID)
wanted[k] = true
}
// json_each 把快照数组摊成一行行成绩,只把命中的那几行传回来 ——
// 事件攒了几十版快照、每版几百条成绩时,不值得整个 JSON 拉回来在 Go 里解析。
query := `SELECT r.event_id, r.created_at, COALESCE(r.message, ''), r.actor_id, ` + snapshotColumns + `
FROM result_revisions r, json_each(r.snapshot_json) v
WHERE r.event_id IN (` + placeholders(len(eventIDs)) + `)
AND json_extract(v.value, '$.entryId') IN (` + placeholders(len(entryIDs)) + `)
ORDER BY r.event_id, r.id`
args := append(append([]any{}, eventIDs...), entryIDs...)
if err := scan(ctx, q, query, args, wanted, out); err != nil {
return nil, err
}
}
return out, nil
}
// snapshotValue 是一条成绩在某一版快照里的「录进去的成绩」。
type snapshotValue struct {
text string
value sql.NullFloat64
unit string
tie int
status string
}
// changed 判断这一版是不是和上一版录的不是同一个成绩了。
//
// 浮点按原值比较:正负零、NaN 这类边界在成绩录入里不会出现(服务端只接受有限数),
// 而 11.58 与 11.580000000000002 这种舍入差异本就不该被当成「改过」。
func (v snapshotValue) changed(o snapshotValue) bool {
if v.text != o.text || v.unit != o.unit || v.tie != o.tie || v.status != o.status {
return true
}
if v.value.Valid != o.value.Valid {
return true
}
return v.value.Valid && v.value.Float64 != o.value.Float64
}
func scan(ctx context.Context, q Queryer, query string, args []any, wanted map[Key]bool, out map[Key]Trail) error {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
prev := make(map[Key]snapshotValue, len(wanted))
for rows.Next() {
var (
eventID, entryID int64
createdAt, message string
actorID sql.NullInt64
note string
value sql.NullFloat64
text, unit, status string
tie int
)
if err := rows.Scan(&eventID, &createdAt, &message, &actorID, &entryID, &text, &value,
&unit, &tie, &status, &note); err != nil {
return err
}
key := Key{EventID: eventID, EntryID: entryID}
if !wanted[key] {
continue
}
cur := snapshotValue{text: text, value: value, unit: unit, tie: tie, status: status}
// 第一次出现只是基线(「发布」本身不是修订),从第二次起才谈得上「改没改」。
if before, ok := prev[key]; ok {
if before.changed(cur) {
trail := out[key]
trail.Count++
trail.At = createdAt
trail.Reason = reasonOf(note, message)
trail.ActorID = nil
if actorID.Valid {
id := actorID.Int64
trail.ActorID = &id
}
out[key] = trail
}
} else {
if _, ok := out[key]; !ok {
out[key] = Trail{} // 见过的标记:Count 0 = 追溯成功、没改过
}
}
prev[key] = cur
}
return rows.Err()
}
// reasonOf 取这次改动的原因,先具体后笼统:
//
// - results.note 是这条成绩自己的备注,「退回草稿 / 作废成绩」时写下的理由就落在它上面;
// - r.message 是修订行自带的消息,目前只有发布时「跳过 N 条只填了成绩文本的草稿」这一种,
// 是**发布级**说明,挂到某一条成绩头上未必对,所以只在前者没有时才用。
//
// 两个都没有就返回空串:调用方只显示时间,不编原因。
func reasonOf(note, message string) string {
if s := strings.TrimSpace(note); s != "" {
return s
}
return strings.TrimSpace(message)
}
func placeholders(n int) string {
if n == 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}