Files
Scoreboard/server/internal/httpapi/repo_results.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

299 lines
8.8 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 httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成员姓名 JSON 数组 ----------
func parseNames(ns sql.NullString) []string {
if !ns.Valid || ns.String == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(ns.String), &out); err != nil {
return nil
}
return out
}
func marshalNames(names []string) any {
if len(names) == 0 {
return nil
}
raw, err := json.Marshal(names)
if err != nil {
return nil
}
return string(raw)
}
// ---------- results ----------
const resultColumns = `id, event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, verified_by, verified_at, note`
func scanResult(row interface{ Scan(...any) error }) (resultDTO, error) {
var (
r resultDTO
rank sql.NullInt64
text, unit sql.NullString
value, override sql.NullFloat64
recordedBy, verifiedBy sql.NullInt64
recordedAt, verifiedAt, note sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie,
&override, &r.Status, &r.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note); err != nil {
return r, err
}
r.Rank = intPtr(rank)
r.ResultText = text.String
r.ResultValue = f64Ptr(value)
r.Unit = unit.String
r.PointsOverride = f64Ptr(override)
r.RecordedBy = i64Ptr(recordedBy)
r.RecordedAt = strPtr(recordedAt)
r.VerifiedBy = i64Ptr(verifiedBy)
r.VerifiedAt = strPtr(verifiedAt)
r.Note = strPtr(note)
return r, nil
}
func (s *server) resultsByEvent(ctx context.Context, eventID int64) ([]resultDTO, error) {
return queryResults(ctx, s.ev, `SELECT `+resultColumns+` FROM results WHERE event_id = ? ORDER BY id`, eventID)
}
func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]resultDTO, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]resultDTO, 0)
for rows.Next() {
r, err := scanResult(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
if err := attachResultPoints(ctx, q, out); err != nil {
return nil, err
}
return out, nil
}
// attachResultPoints 给已发布成绩补上得分(该成绩产生的 result 流水合计)。
// 草稿没有流水,保持 nil;录入页据此预览发布后的分值变化。
func attachResultPoints(ctx context.Context, q queryer, items []resultDTO) error {
ids := make([]any, 0, len(items))
index := make(map[int64]int, len(items))
for i := range items {
if items[i].State != "published" {
continue
}
ids = append(ids, items[i].ID)
index[items[i].ID] = i
}
if len(ids) == 0 {
return nil
}
rows, err := q.QueryContext(ctx,
`SELECT result_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND source_type = 'result' AND result_id IN (`+placeholders(len(ids))+`)
GROUP BY result_id`, ids...)
if err != nil {
return internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
resultID int64
points float64
)
if err := rows.Scan(&resultID, &points); err != nil {
return internal(err)
}
if i, ok := index[resultID]; ok {
v := points
items[i].Points = &v
}
}
if err := rows.Err(); err != nil {
return internal(err)
}
return nil
}
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
r, err := scanResult(s.ev.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, internal(err)
}
items := []resultDTO{r}
if err := attachResultPoints(ctx, s.ev, items); err != nil {
return resultDTO{}, err
}
return items[0], nil
}
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if eventID > 0 {
where = append(where, "event_id = ?")
args = append(args, eventID)
}
if state != "" {
where = append(where, "state = ?")
args = append(args, state)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM results`+clause, args...).Scan(&total); err != nil {
return pageResult[resultDTO]{}, internal(err)
}
query := `SELECT ` + resultColumns + ` FROM results` + clause + ` ORDER BY id LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
items, err := queryResults(ctx, s.ev, query, args...)
if err != nil {
return pageResult[resultDTO]{}, err
}
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩直接拒绝(STATE_INVALID),
// 必须先撤销发布再改,避免 rank 被就地清空而流水不动。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
for i, it := range items {
if it.EntryID == 0 {
return nil, badRequest("results[].entryId 必填", i)
}
var entryEvent int64
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("entryId 不存在", it.EntryID)
}
return nil, internal(err)
}
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
// 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
// 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
// 尚无成绩,正常新建草稿。
case err != nil:
return nil, internal(err)
case state == string(domain.ResultStatePublished):
return nil, stateInvalid("该成绩已发布,请先撤销发布再修改")
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, datetime('now'), ?, datetime('now'))
ON CONFLICT(entry_id) DO UPDATE SET
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = COALESCE(excluded.unit, results.unit),
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
note = excluded.note,
recorded_by = excluded.recorded_by,
recorded_at = excluded.recorded_at,
updated_at = datetime('now')`,
eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"),
nullID(userID), nullStr(it.Note)); err != nil {
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.resultsByEvent(ctx, eventID)
}
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " AND "
}
out += p
}
return out
}
// ---------- ledger ----------
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
}
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.LedgerEntry, 0)
for rows.Next() {
var (
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
return nil, internal(err)
}
l.AthleteID = athlete.Int64
l.EventID = event.Int64
l.EntryID = entry.Int64
l.ResultID = result.Int64
l.CreatedAt = created.String
out = append(out, l)
}
return out, rows.Err()
}