// 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, ¬e); 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), ",") }