用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
270 lines
11 KiB
Go
270 lines
11 KiB
Go
// Package dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
|
||
//
|
||
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
|
||
// 本包只依赖 domain 与标准库,不做任何 IO。
|
||
package dto
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
||
// 与 web/src/shared/types.ts 的 EventSettings 一一对应。
|
||
type EventSettings struct {
|
||
Event EventSettingsEvent `json:"event"`
|
||
Display EventSettingsDisplay `json:"display"`
|
||
Standings EventSettingsStandings `json:"standings"`
|
||
DefaultProfileID *int64 `json:"defaultProfileId"`
|
||
RequireApproval bool `json:"requireApproval"`
|
||
}
|
||
|
||
// EventSettingsEvent 是赛事基本信息。
|
||
type EventSettingsEvent struct {
|
||
Name string `json:"name"`
|
||
Subtitle *string `json:"subtitle"`
|
||
Host *string `json:"host"`
|
||
LogoPath *string `json:"logoPath"`
|
||
PrimaryColor *string `json:"primaryColor"`
|
||
}
|
||
|
||
// EventSettingsDisplay 是大屏展示配置。
|
||
type EventSettingsDisplay struct {
|
||
Background *string `json:"background"`
|
||
Logo *string `json:"logo"`
|
||
Title string `json:"title"`
|
||
Subtitle *string `json:"subtitle"`
|
||
PrimaryColor *string `json:"primaryColor"`
|
||
RotateSecond int `json:"rotateSeconds"`
|
||
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
|
||
Announce *string `json:"announce"`
|
||
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
|
||
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
|
||
FontTitle *string `json:"fontTitle"`
|
||
FontBody *string `json:"fontBody"`
|
||
// LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。
|
||
// 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。
|
||
LiveEvents []int64 `json:"liveEvents"`
|
||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||
LiveSource string `json:"liveSource"`
|
||
}
|
||
|
||
// 「大屏实时赛况」的两个设置键与取值约束。
|
||
const (
|
||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||
SettingKeyLiveEvents = "display.live_events"
|
||
SettingKeyLiveSource = "display.live_source"
|
||
// SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。
|
||
// 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。
|
||
// 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。
|
||
SettingKeyLiveOnPublish = "standings.live_on_publish"
|
||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||
MaxLiveEvents = 2
|
||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||
LiveSourceAuto = "auto"
|
||
LiveSourceManual = "manual"
|
||
)
|
||
|
||
// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。
|
||
//
|
||
// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过,
|
||
// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。
|
||
func ParseLiveEventIDs(raw string) []int64 {
|
||
out := make([]int64, 0, MaxLiveEvents)
|
||
seen := make(map[int64]bool)
|
||
for _, part := range strings.Split(raw, ",") {
|
||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||
if err != nil || id <= 0 || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
out = append(out, id)
|
||
if len(out) == MaxLiveEvents {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。
|
||
func FormatLiveEventIDs(ids []int64) string {
|
||
parts := make([]string, 0, len(ids))
|
||
for _, id := range ids {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, strconv.FormatInt(id, 10))
|
||
if len(parts) == MaxLiveEvents {
|
||
break
|
||
}
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。
|
||
func NormalizeLiveSource(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "", LiveSourceAuto:
|
||
return LiveSourceAuto
|
||
case LiveSourceManual:
|
||
return LiveSourceManual
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// EventSettingsStandings 是榜单口径配置。
|
||
type EventSettingsStandings struct {
|
||
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
||
RankingMode string `json:"rankingMode"`
|
||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||
// LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。
|
||
LiveOnPublish bool `json:"liveOnPublish"`
|
||
}
|
||
|
||
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
|
||
//
|
||
// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
|
||
// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
|
||
// 系数属于系统配置,必须置空/清零后再上线。
|
||
// Display 整块保留:其中的公告(announce)就是给大屏底栏用的展示字段,裁剪掉它会丢公告。
|
||
func (s EventSettings) WithoutAdminFields() EventSettings {
|
||
return EventSettings{
|
||
Event: s.Event,
|
||
Display: s.Display,
|
||
Standings: EventSettingsStandings{
|
||
RankingMode: s.Standings.RankingMode,
|
||
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
|
||
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
|
||
// 结算方式不是系统机密,而是录入员必须知道的现场行为:
|
||
// 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。
|
||
LiveOnPublish: s.Standings.LiveOnPublish,
|
||
},
|
||
}
|
||
}
|
||
|
||
// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。
|
||
//
|
||
// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings
|
||
// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。
|
||
type SessionSettings struct {
|
||
SessionHours int `json:"sessionHours"`
|
||
// DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。
|
||
DefaultHours int `json:"defaultSessionHours"`
|
||
MinHours int `json:"minHours"`
|
||
MaxHours int `json:"maxHours"`
|
||
// ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。
|
||
ExpiresAt *string `json:"expiresAt"`
|
||
}
|
||
|
||
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
||
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
||
type RuleSwitches struct {
|
||
RecordBonusEnabled bool `json:"recordBonusEnabled"`
|
||
AdjustmentsEnabled bool `json:"adjustmentsEnabled"`
|
||
AdjustmentReasonRequired bool `json:"adjustmentReasonRequired"`
|
||
MedalCountsTeamEvents bool `json:"medalCountsTeamEvents"`
|
||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||
}
|
||
|
||
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
|
||
//
|
||
// 默认值必须与 REST 的 getSettings 逐条一致:大屏首包走 snapshot、管理端走 /api/settings,
|
||
// 两边口径不同会让同一块屏刷新前后显示不一样的标题。
|
||
// defaultProfileId 需要查 scoring_profiles 反查 code,属于 IO,由调用方补填。
|
||
func ParseEventSettings(values map[string]string) EventSettings {
|
||
var out EventSettings
|
||
|
||
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
|
||
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
|
||
out.Event.Host = nilIfEmpty(values["event.organizer"])
|
||
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
|
||
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
|
||
|
||
out.Display.Background = nilIfEmpty(values["display.background"])
|
||
out.Display.Logo = nilIfEmpty(values["display.logo"])
|
||
if out.Display.Logo == nil {
|
||
out.Display.Logo = out.Event.LogoPath
|
||
}
|
||
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
|
||
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
|
||
if out.Display.Subtitle == nil {
|
||
out.Display.Subtitle = out.Event.Subtitle
|
||
}
|
||
out.Display.PrimaryColor = out.Event.PrimaryColor
|
||
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
|
||
// 字体按 id 字符串原样下发,前端解析成字体栈;空串与缺键都表示用内置默认字体。
|
||
out.Display.FontTitle = nilIfEmpty(values["display.font_title"])
|
||
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
|
||
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
|
||
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
|
||
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
|
||
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
||
|
||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||
// showIndividualBoard 是历史别名:未单独配置时沿用旧的 show_individual_medals 取值。
|
||
out.Standings.ShowIndividualBoard = boolDefault(
|
||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
||
// 缺键 = 实时结算:老赛事升级后行为必须与升级前完全一致。
|
||
out.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], true)
|
||
|
||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||
return out
|
||
}
|
||
|
||
func nilIfEmpty(s string) *string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return &s
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func atoiDefault(v string, def int) int {
|
||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func atofDefault(v string, def float64) float64 {
|
||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func boolDefault(v string, def bool) bool {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "true", "1", "yes":
|
||
return true
|
||
case "false", "0", "no":
|
||
return false
|
||
default:
|
||
return def
|
||
}
|
||
}
|