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:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -27,13 +27,17 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
|
||||
e domain.Event
|
||||
scheduledAt sql.NullString
|
||||
venue, remark sql.NullString
|
||||
awardMedals int
|
||||
)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals
|
||||
FROM events WHERE id = ?`, eventID).
|
||||
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode,
|
||||
&awardMedals)
|
||||
e.AwardMedals = awardMedals != 0
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Event{}, notFound("小项不存在")
|
||||
}
|
||||
@@ -81,7 +85,8 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
|
||||
|
||||
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -91,21 +96,66 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
|
||||
out := make([]domain.ScoreChannel, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
|
||||
&countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
|
||||
if countToTeam == 0 {
|
||||
c.CountToTeam = boolPtr(false)
|
||||
}
|
||||
if countToMedals == 0 {
|
||||
c.CountToMedals = boolPtr(false)
|
||||
}
|
||||
if countToTeamMedals == 0 {
|
||||
c.CountToTeamMedals = boolPtr(false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadTeamRanksQ 读「按单位录入的团体名次」(channel_manual_ranks),按组别分组返回。
|
||||
//
|
||||
// 按组别分组是必须的:computeEventScore 会逐组别调用引擎,组别名次只在组别内有效,
|
||||
// 一次查询整小项再分发,避免每个组别各发一条 SQL。
|
||||
// 只取 scope=team + basis=manual 的行:这两列是这张表的定位键,
|
||||
// 其它 scope/basis 的行属于别的通道形态,引擎的团体手工通道也不该看到它们。
|
||||
func loadTeamRanksQ(ctx context.Context, q queryer, eventID, categoryID int64) (map[int64][]domain.TeamManualRank, error) {
|
||||
query := `SELECT category_id, team_id, rank FROM channel_manual_ranks
|
||||
WHERE event_id = ? AND scope = 'team' AND basis = 'manual'`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
query += ` AND category_id = ?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
query += ` ORDER BY category_id, rank, team_id`
|
||||
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.TeamManualRank)
|
||||
for rows.Next() {
|
||||
var (
|
||||
catID int64
|
||||
r domain.TeamManualRank
|
||||
)
|
||||
if err := rows.Scan(&catID, &r.TeamID, &r.Rank); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[catID] = append(out[catID], r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
|
||||
out := make(map[int64]domain.ScoringProfile, len(ids))
|
||||
for id := range ids {
|
||||
@@ -309,6 +359,10 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
teamRanks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
|
||||
var total domain.EventScore
|
||||
for _, cat := range cats {
|
||||
@@ -324,6 +378,8 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
Results: results,
|
||||
RecordBonus: &bonusCfg,
|
||||
RecordBreaks: breaks,
|
||||
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
|
||||
TeamRanks: teamRanks[cat.ID],
|
||||
})
|
||||
if err != nil {
|
||||
return domain.EventScore{}, mapDomainError(err)
|
||||
@@ -373,25 +429,25 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
|
||||
ledger := score.Ledger
|
||||
if categoryID > 0 {
|
||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||
// 「无报名项归属」的流水无法按组别区分,上面已把本小项的全部这类流水一并作废;
|
||||
// 这里补算整项的同类流水,避免按组别重算丢掉其它组别的得分。
|
||||
hasSubject, err := hasSubjectChannel(ctx, tx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasAggregate {
|
||||
if hasSubject {
|
||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||
for _, l := range score.Ledger {
|
||||
if !isAggregateLedger(l) {
|
||||
if !isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
for _, l := range all.Ledger {
|
||||
if isAggregateLedger(l) {
|
||||
if isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
@@ -406,35 +462,98 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// 名次与流水同源同事务:流水改了名次必然跟着变,分开落库就会出现「榜上有分、名次还是旧的」。
|
||||
return writeChannelRanksTx(ctx, tx, score, eventID, categoryID)
|
||||
}
|
||||
|
||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||
// 落库时 entry_id 与 result_id 都为空。
|
||||
func isAggregateLedger(l domain.LedgerEntry) bool {
|
||||
// isSubjectLedger 判断流水是否来自「按主体排名」的通道,这类流水的 EntryID 与 ResultID 都为空:
|
||||
//
|
||||
// - 聚合通道(basis=aggregate):名次主体是单位(team)或运动员(athlete),
|
||||
// 不对应任何一条报名项;
|
||||
// - 团体手工通道(scope=team + basis=manual,主体来自 channel_manual_ranks):
|
||||
// 录入的是单位名次,同样没有报名项与成绩行(走团体报名项兜底时才有 entry_id,
|
||||
// 那种流水仍归属组别,不算在这里)。
|
||||
//
|
||||
// 判据必须涵盖这两类:按组别重算时它们都无法靠 entry_id 定位(见 writeLedgerTx 的作废范围),
|
||||
// 少认一类就会让另一类旧流水作废不掉或补不回来,该通道的分数随之翻倍或丢失。
|
||||
func isSubjectLedger(l domain.LedgerEntry) bool {
|
||||
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
|
||||
}
|
||||
|
||||
// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
|
||||
func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
|
||||
// hasSubjectChannel 判断小项是否配置了「按主体排名」的通道(聚合 / 团体手工);
|
||||
// 没有时按组别重算无需补算整项流水。
|
||||
func hasSubjectChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
|
||||
`SELECT COUNT(*) FROM event_score_channels
|
||||
WHERE event_id = ? AND (basis = 'aggregate' OR (scope = 'team' AND basis = 'manual'))`, eventID).Scan(&n); err != nil {
|
||||
return false, internal(err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
|
||||
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
|
||||
// writeChannelRanksTx 按引擎产出的名次明细全量重建 channel_ranks:先删范围内旧行,再逐条插入。
|
||||
//
|
||||
// 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。
|
||||
// SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。
|
||||
// AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels
|
||||
// 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。
|
||||
func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error {
|
||||
if categoryID > 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_ranks
|
||||
WHERE category_id = ? AND channel_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)`,
|
||||
categoryID, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE event_id = ?`, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
for _, rr := range score.Ranked {
|
||||
// 没有通道(只排名不计分的小项走退化路径,通道 id 为 0)与非法名次都落不了这张表:
|
||||
// 表上 channel_id 有外键、rank 有 CHECK(rank >= 1),硬插只会整笔事务失败。
|
||||
if rr.ChannelID == 0 || rr.Rank <= 0 || rr.SubjectKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventID, rr.CategoryID, rr.ChannelID, rr.Basis, rr.Scope, rr.SubjectKey,
|
||||
nullID(rr.EntryID), nullID(rr.ResultID), nullID(rr.AthleteID), nullID(rr.TeamID),
|
||||
rr.Rank, rr.Points, nullF64(rr.ResultValue), nullStr(resultStatusPtr(rr.Status))); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resultStatusPtr 把成绩状态转成可空串:空状态落 NULL,与 results.status 的口径一致。
|
||||
func resultStatusPtr(s domain.ResultStatus) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
v := string(s)
|
||||
return &v
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的「个人名次」写回 results.rank。
|
||||
//
|
||||
// results.rank 的语义收窄为个人名次:只处理 scope=athlete 且带真实成绩行的名次。
|
||||
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
|
||||
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
|
||||
//
|
||||
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
|
||||
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
|
||||
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
|
||||
best := make(map[int64]domain.RankedResult)
|
||||
for _, rr := range ranked {
|
||||
if rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
cur, ok := best[rr.ResultID]
|
||||
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
|
||||
if !ok || preferComputedRank(rr, cur) {
|
||||
best[rr.ResultID] = rr
|
||||
}
|
||||
}
|
||||
@@ -446,6 +565,14 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
|
||||
func preferComputedRank(a, b domain.RankedResult) bool {
|
||||
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
|
||||
return a.Basis == domain.BasisResult
|
||||
}
|
||||
return a.ChannelID < b.ChannelID
|
||||
}
|
||||
|
||||
// ---------- 发布 / 撤销 / 作废 ----------
|
||||
|
||||
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
|
||||
@@ -611,7 +738,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
|
||||
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
|
||||
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
||||
score, err := computeEventScore(ctx, tx, in.EventID, catID)
|
||||
@@ -783,29 +910,26 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
reason, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||
// withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算;
|
||||
// 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。
|
||||
settle := true
|
||||
if live, err := liveOnPublishQ(ctx, tx); err != nil {
|
||||
return resultDTO{}, err
|
||||
} else if !live {
|
||||
settle, err = scopeSettled(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 作废的成绩不再占名次:results.rank 这一列必须让出来。作废行仍是 state='published',
|
||||
// 而导出(分项成绩表)读的正是 results.rank,不清就会把作废行列成第 1 名、与真正的冠军并列;
|
||||
// 整组全部作废时更是几条成绩都挂着第 1 名。与批量退赛同口径(entries_batch 显式清 rank)。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = NULL WHERE id = ?`, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
if settle {
|
||||
score, err := computeEventScore(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 作废是回滚动作(改的是「谁还算数」,不是「录了多少」),与撤销发布、批量退赛/恢复/移出
|
||||
// 同一口径:无条件重算并推榜,不看 standings.live_on_publish、也不看 scopeSettled。
|
||||
// 否则没录完的组里作废一条成绩会留下一整套旧名次与旧流水(榜上有分、成绩却撤了),
|
||||
// 被作废的人会一直挂到下一次结算。
|
||||
// 这一刀不会把「未录完的组」提前变成「已结算」:结算判据只数已发布成绩,作废不写 state
|
||||
// (仍是 published、仍算「已录出」),延迟窗口里其它批次照旧等录完才结算。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||
@@ -815,10 +939,9 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if settle {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 名次与流水刚改成当前口径,必须推榜单,否则大屏还挂着作废前的旧画面。
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
|
||||
Reference in new issue
Block a user