## 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 例 + 语法检查。
311 lines
9.4 KiB
Go
311 lines
9.4 KiB
Go
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, ¬e); 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)
|
||
}
|
||
// 名次是手填名次项目的输入,results.rank 的约束是 rank IS NULL OR rank >= 1:
|
||
// 留空 = 不计名次(合法),填了就必须不小于 1。不在这里拦就会一路落到 SQLite,
|
||
// 报成 500 + 原始约束文本,录入员看不出是哪一格填错了。
|
||
if it.Rank != nil && *it.Rank < 1 {
|
||
return nil, badRequest("名次需要是不小于 1 的整数(留空表示不计名次)", it.Rank)
|
||
}
|
||
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)
|
||
}
|
||
status, err := normalizeResultStatus(it.Status)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 已发布成绩不能被「存草稿」就地改写: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), status,
|
||
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, count_to_team 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
|
||
countToTeam int
|
||
)
|
||
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
|
||
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created, &countToTeam); 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
|
||
l.CountToTeam = countToTeam != 0
|
||
out = append(out, l)
|
||
}
|
||
return out, rows.Err()
|
||
}
|