feat(display): 大屏「赛事日程侧边栏」(手填日程 + 竖向进度轴 + 热开关 + 每屏覆盖);版本 dev_0.5.16
主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 id**。
服务端(走大屏公告那条路 —— PUT /api/settings 是 admin-only,会与「录入员可改」冲突)
- 新增 `schedule` 动作 + 白名单;`POST /api/display/command` 同一个 handler:**先校验 → 再转发 → 最后落库**
(校验排在转发前,非法配置不会在大屏上闪一帧)
- 落库到赛事库 settings 的 `display.schedule`(**不新建表、不写迁移**,随赛事包走);
与公告共用那份「写设置键 + bumpVersion」的实现
- **读写不对称容错**:写侧严格 400(含中文提示与第几条),读侧坏数据一律静默当空 —— 老数据 / 手改库不会炸大屏
- 上限:40 条;time ≤20 / title ≤40 / note ≤60(按字符,清洗控制字符);宽度夹 15–40;覆盖 ≤50 条
- 口径收口三处:**时间允许为空**(与管理端同口径)、**showNote 缺键 = true**(区分「键不存在」与「显式 false」)、
**`inherit` 不落库**(不再白占覆盖预算)
大屏端
- 新增 `ScheduleSidebar.vue` + `schedulePanel.ts`(纯函数:开窗 / 三态 / 整体进度 / 字号与行数 / 按显示宽度截断 /
单屏覆盖 / 抑制 / 指纹去重)
- 字号是**舞台设计坐标**(1920×1080),靠 `.stage` 的 `transform: scale()` 整体缩放,**不做二次缩放**
(wiring 断言禁止出现 `window.`/`innerWidth`/`vh`);复用既有的 `fitRowFontPx`(**16–56px 硬上下限**)与 `textEm`;
固定点迭代 ≤4 轮;**贴下限时宁可行数少也不缩字**;标题 ≤3 行、备注 ≤2 行,超出截断
- 容器 `position:absolute; inset:0; z-index:50`(**不用 fixed** —— `.stage` 带 transform,是 fixed 后代的包含块);
**独立于 detail 状态机**(goto 会关明细、明细会停轮播,侧边栏必须不受影响)
- 深色半透明 + `backdrop-filter`,并带 `@supports not (backdrop-filter: blur(1px))` 退化为更不透明的深底
- 关着时**不留任何 DOM**(逐字节等同于没有这个功能);`prefers-reduced-motion` 下不做动效
管理端
- 大屏控制页(左列、公告之后)新增「赛事日程」卡片:条目表格(时间/标题/备注/已完成 + 上移/下移/删除)、
总开关、备注上屏、位置与宽度、**每块屏三态覆盖**(跟随 / 强制开 / 强制关;切回「跟随」会真删掉那条键)
- 保存走 `adminApi.displayCommand`(**不走 admin-only 的设置接口**);本地校验不过**一个请求都不发**
- 排序只有上移 / 下移(该页面禁止挂 pointerdown/mousedown 监听,有既有测试钉着)
测试
- 服务端 16 个包全绿;前端 **1174 例**全过(基线 1096,本批 +78:大屏端 44 + 管理端 34)
- 大屏端另有 **40 项 playwright 实机**(用 `page.routeWebSocket` 假造实时通道):开/关后 DOM 无残留、
五个页面下都在、`lock=0` 真等轮播翻页且侧边栏不掉、明细与公告期间自动收起并自动回来、
**1080p 与 4K 字号完全相同**、`reducedMotion` 下无动画;并钉住两条硬指标(真机列表高度 ≥ 纯函数预算、
容量×行高 ≤ 真机高度)
文档:`docs/升级说明` 新增 `dev_0.5.16`;`使用手册` 9.2 + 17.6;`常见问题` Q108–Q111;`上线检查清单`/`部署指南`
各一条;`devdocs/05·07·13` 同步。
⚠️ 一条**既有边界**(本版故意没改,按主人「兼容性最大化」的要求):导出的名次口径是「通道名次优先」、
屏幕是「成绩行名次优先」,只在「先按决赛计分发布、之后又只更新名次发布一次」这种序列下分叉
(导出显示上次计分的名次)。正常赛程遇不到;遇到时对该组再「按决赛成绩发布」一次即恢复一致。
已写进《升级说明》④ 与《常见问题》Q111。
This commit is contained in:
1 parent
56ed90ff8b
commit
9c52dc4b38
33 files changed
+4736
-10
No files matched your search
@@ -5,8 +5,10 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
||||
@@ -48,6 +50,208 @@ type EventSettingsDisplay struct {
|
||||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||||
LiveSource string `json:"liveSource"`
|
||||
// Schedule 是赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。
|
||||
// 库里以 display.schedule 存一份 JSON,由大屏指令写入;读不出来(缺键 / 坏 JSON)时是 null,
|
||||
// 大屏按「没有这个功能」处理 —— 一条脏设置绝不能让大屏整块不显示。
|
||||
Schedule *ScheduleConfig `json:"schedule"`
|
||||
}
|
||||
|
||||
// ---------- 赛事日程侧边栏 ----------
|
||||
|
||||
// SettingKeySchedule 是整份日程侧边栏配置在 settings 表里的键,值是一份 JSON。
|
||||
//
|
||||
// 与 display.announce_text 同一套机制:由 POST /api/display/command 的 schedule 指令写入
|
||||
// (PUT /api/settings 是 admin-only,而现场要求录入员也能切),随 snapshot.settings 下发大屏。
|
||||
// 不新建表、不写迁移。
|
||||
const SettingKeySchedule = "display.schedule"
|
||||
|
||||
// 日程的取值上限。长度一律按**字符**(rune)算:中文标题按字节算会被切出半个字。
|
||||
const (
|
||||
// MaxScheduleItems 是日程条目数上限:一场赛事的日程排不下更多,也挡住「整屏全是条目」这种
|
||||
// 把大屏刷爆的输入。
|
||||
MaxScheduleItems = 40
|
||||
// MaxScheduleTimeChars / TitleChars / NoteChars 是单项三个文本字段的字符数上限。
|
||||
MaxScheduleTimeChars = 20
|
||||
MaxScheduleTitleChars = 40
|
||||
MaxScheduleNoteChars = 60
|
||||
// MaxScheduleOverrides 是单屏覆盖的条数上限(键是实例 id)。
|
||||
MaxScheduleOverrides = 50
|
||||
// MaxScheduleOverrideKeyChars 是实例 id 的字符数上限,与设备自报标签同一量级。
|
||||
MaxScheduleOverrideKeyChars = 120
|
||||
// 侧边栏宽度按「占屏百分比」存,夹在这个区间:更窄放不下字,更宽会盖住大屏正文。
|
||||
MinScheduleWidthPercent = 15
|
||||
MaxScheduleWidthPercent = 40
|
||||
DefaultScheduleWidthPercent = 22
|
||||
)
|
||||
|
||||
// 侧边栏贴哪一边。认不出的取值一律回落 ScheduleSideRight(与默认口径一致)。
|
||||
const (
|
||||
ScheduleSideLeft = "left"
|
||||
ScheduleSideRight = "right"
|
||||
)
|
||||
|
||||
// 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。
|
||||
const (
|
||||
ScheduleOverrideInherit = "inherit"
|
||||
ScheduleOverrideOn = "on"
|
||||
ScheduleOverrideOff = "off"
|
||||
)
|
||||
|
||||
// ScheduleItem 是日程里的一条。**手填**,与小项、报名无关,不参与算分。
|
||||
//
|
||||
// Time 是自由文本(「08:30」「上午」「待定」都行),**允许为空**(先排标题、时间回头补):
|
||||
// 服务端不解析、不比较、不排序,列表顺序就是排定的顺序(「当前项」= 第一个 done=false 的项,
|
||||
// 由大屏算)。空的只是不上屏显示时间那一格。管理端 scheduleEditor.ts 的 validateSchedule
|
||||
// 同一口径:时间只卡长度,标题才必填。
|
||||
// note / done 是可缺字段,缺省即「没有备注」「未完成」。
|
||||
type ScheduleItem struct {
|
||||
Time string `json:"time"`
|
||||
Title string `json:"title"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
|
||||
// ScheduleConfig 是整份日程侧边栏配置。保存是**整份替换**,服务端不做局部合并
|
||||
// (客户端手上就有整份配置,合并只会让两边不一致)。
|
||||
//
|
||||
// InstanceOverrides 按**实例 id**(GET /api/display/instances 的 id)覆盖总开关;
|
||||
// 缺省即「全部 inherit」,且 `inherit` 不进 map(它就是「没有这条设置」,见下面两处解析)。
|
||||
// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。
|
||||
type ScheduleConfig struct {
|
||||
Items []ScheduleItem `json:"items"`
|
||||
Visible bool `json:"visible"`
|
||||
Side string `json:"side"`
|
||||
WidthPercent float64 `json:"widthPercent"`
|
||||
// ShowNote 缺省 = **true**(备注默认上屏,与管理端表单、大屏解析两边一致)。
|
||||
// 缺键时不能吃 Go 的零值 false,收口在 ScheduleShowNote。
|
||||
ShowNote bool `json:"showNote"`
|
||||
InstanceOverrides map[string]string `json:"instanceOverrides,omitempty"`
|
||||
}
|
||||
|
||||
// ScheduleShowNote 从原始 JSON 对象里读 showNote,缺省回落 **true**(备注默认上屏)。
|
||||
//
|
||||
// 为什么单独解一次而不是直接吃 ScheduleConfig 的 bool:**bool 的零值分不出「键不存在」与
|
||||
// 「显式 false」**,而这两者的语义完全不同 —— 库里的 JSON 只要没带这个键(别的客户端写的、
|
||||
// 手工改库、老数据),零值就会让服务端下发 false,备注在没有任何人关过它的前提下莫名不上屏。
|
||||
// 管理端默认 true、大屏解析 `showNote !== false`(也是 true),服务端必须同口径。
|
||||
//
|
||||
// 值为 `null` 或不是布尔时同样回落 true(与前端两边的「不是 false 就当开」一致)。
|
||||
// 解成 `*bool` 而不是 `bool` 正是为了这个:Go 把 JSON `null` 解进 `bool` 是**静默不动**、
|
||||
// 也不报错,用值类型接就会把它当 false。
|
||||
func ScheduleShowNote(raw []byte) bool {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return true
|
||||
}
|
||||
value, ok := obj["showNote"]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
var b *bool
|
||||
if err := json.Unmarshal(value, &b); err != nil || b == nil {
|
||||
return true
|
||||
}
|
||||
return *b
|
||||
}
|
||||
|
||||
// NormalizeScheduleSide 归一左右取值:只认 left / right,其它(含空串)回落 right。
|
||||
func NormalizeScheduleSide(raw string) string {
|
||||
if strings.ToLower(strings.TrimSpace(raw)) == ScheduleSideLeft {
|
||||
return ScheduleSideLeft
|
||||
}
|
||||
return ScheduleSideRight
|
||||
}
|
||||
|
||||
// NormalizeScheduleWidth 把宽度夹到 [MinScheduleWidthPercent, MaxScheduleWidthPercent];
|
||||
// 缺省(非正数)回落 DefaultScheduleWidthPercent。
|
||||
//
|
||||
// 夹取而不是报错:宽度越界多半是前端算错了一个数,为它让整份日程存不下去不划算,
|
||||
// 而且夹到一个「能看」的值没有信息损失。
|
||||
func NormalizeScheduleWidth(percent float64) float64 {
|
||||
switch {
|
||||
case percent <= 0:
|
||||
return DefaultScheduleWidthPercent
|
||||
case percent < MinScheduleWidthPercent:
|
||||
return MinScheduleWidthPercent
|
||||
case percent > MaxScheduleWidthPercent:
|
||||
return MaxScheduleWidthPercent
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
// ParseScheduleValue 解析 settings 里的 display.schedule(**读入口,永不报错**)。
|
||||
//
|
||||
// 这个键可能被手工改过、被别的版本写过、或者写到一半断电,所以口径与 record_bonus.levels、
|
||||
// ParseLiveEventIDs 一致:坏 JSON 整个当「没有这个功能」(返回 nil),结构上不成立的那几条
|
||||
// (**标题**缺失的条目、非三态的覆盖、认不出的左右)丢掉,条数取前 MaxScheduleItems /
|
||||
// MaxScheduleOverrides 条。**一条脏设置绝不能让大屏整块不显示**。
|
||||
//
|
||||
// 与写入口(httpapi 的 scheduleFromPayload)刻意不对称:那边越界报 400 而不是静默截断,
|
||||
// 因为那是人在操作、要立刻知道哪一项不合规;这边面对的是已经躺在库里的数据,报错没有出口。
|
||||
//
|
||||
// 两处「缺省不等于零值」的收口都在这里:showNote 缺键当 **true**(见 ScheduleShowNote),
|
||||
// `inherit` 的覆盖当「没有这条设置」丢掉(前端两边解析也是这么做的)。
|
||||
//
|
||||
// 文本只去首尾空白、不剥控制字符:写入口已经清洗过,老数据里万一还留着换行,
|
||||
// 前端按普通空白渲染,不足以炸大屏;而读入口反向改写内容会掩盖数据被谁改坏过。
|
||||
func ParseScheduleValue(raw string) *ScheduleConfig {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var cfg ScheduleConfig
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]ScheduleItem, 0, len(cfg.Items))
|
||||
for _, it := range cfg.Items {
|
||||
it.Time = strings.TrimSpace(it.Time)
|
||||
it.Title = strings.TrimSpace(it.Title)
|
||||
it.Note = strings.TrimSpace(it.Note)
|
||||
// 只认标题必填:标题是列表主行,缺了就是一条空白线,整条丢掉。**时间可以为空**
|
||||
// (写入口允许,管理端也允许),空串照样保留这条,大屏只是不画时间那一格。
|
||||
if it.Title == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
if len(items) == MaxScheduleItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var overrides map[string]string
|
||||
if len(cfg.InstanceOverrides) > 0 {
|
||||
overrides = make(map[string]string, len(cfg.InstanceOverrides))
|
||||
for id, value := range cfg.InstanceOverrides {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || utf8.RuneCountInString(id) > MaxScheduleOverrideKeyChars {
|
||||
continue
|
||||
}
|
||||
// inherit 是「没有这条设置」,缺省就是它:存着只会白占 MaxScheduleOverrides
|
||||
// 的条数预算(管理端与大屏解析都会把这种条目丢掉,两边不会照原样再写回来)。
|
||||
switch value {
|
||||
case ScheduleOverrideOn, ScheduleOverrideOff:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
overrides[id] = value
|
||||
if len(overrides) == MaxScheduleOverrides {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(overrides) == 0 {
|
||||
overrides = nil
|
||||
}
|
||||
}
|
||||
|
||||
return &ScheduleConfig{
|
||||
Items: items,
|
||||
Visible: cfg.Visible,
|
||||
Side: NormalizeScheduleSide(cfg.Side),
|
||||
WidthPercent: NormalizeScheduleWidth(cfg.WidthPercent),
|
||||
ShowNote: ScheduleShowNote([]byte(raw)),
|
||||
InstanceOverrides: overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// 「大屏实时赛况」的两个设置键与取值约束。
|
||||
@@ -204,6 +408,8 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
||||
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
|
||||
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
||||
// 赛事日程侧边栏:由大屏指令写入(recorder 也能推),缺键 / 坏 JSON 一律 null。
|
||||
out.Display.Schedule = ParseScheduleValue(values[SettingKeySchedule])
|
||||
|
||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -289,3 +290,198 @@ func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
|
||||
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 赛事日程侧边栏 ----------
|
||||
|
||||
// 日程存在 display.schedule 里,随 ParseEventSettings 一起下发;
|
||||
// 缺键(老赛事 / 从没配过)必须是 null,前端按「没有这个功能」处理。
|
||||
func TestParseEventSettingsSchedule(t *testing.T) {
|
||||
out := ParseEventSettings(map[string]string{
|
||||
SettingKeySchedule: `{"items":[{"time":"08:30","title":"开幕式","done":true}],` +
|
||||
`"visible":true,"side":"left","widthPercent":22,"showNote":true}`,
|
||||
})
|
||||
if out.Display.Schedule == nil {
|
||||
t.Fatal("配置了日程就应下发")
|
||||
}
|
||||
if !out.Display.Schedule.Visible || out.Display.Schedule.Side != ScheduleSideLeft {
|
||||
t.Fatalf("日程配置应原样解析: %+v", out.Display.Schedule)
|
||||
}
|
||||
if len(out.Display.Schedule.Items) != 1 || out.Display.Schedule.Items[0].Title != "开幕式" {
|
||||
t.Fatalf("条目应原样解析: %+v", out.Display.Schedule.Items)
|
||||
}
|
||||
|
||||
// 时间留空的条目照样下发(只是那一条不显示时间格)。
|
||||
withEmptyTime := ParseEventSettings(map[string]string{
|
||||
SettingKeySchedule: `{"items":[{"time":"","title":"时间回头补"}],"visible":true}`,
|
||||
})
|
||||
if items := withEmptyTime.Display.Schedule.Items; len(items) != 1 || items[0].Time != "" {
|
||||
t.Fatalf("时间为空的条目应照样下发: %+v", items)
|
||||
}
|
||||
// 这个 payload 没带 showNote:缺省的语义是 **true**(备注默认上屏),不是 Go 的零值 false。
|
||||
if !withEmptyTime.Display.Schedule.ShowNote {
|
||||
t.Fatal("缺 showNote 键时应缺省为 true(备注默认上屏)")
|
||||
}
|
||||
|
||||
if got := ParseEventSettings(nil).Display.Schedule; got != nil {
|
||||
t.Fatalf("没配过日程时应为 null,得到 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// showNote 的缺省 = **true**:bool 的零值分不出「键不存在」与「显式 false」,而两者语义相反
|
||||
// ——库里那份 JSON 没带这个键(别的客户端写的 / 手工改库 / 老数据)时,服务端下发 false 会
|
||||
// 让备注在没人关过它的前提下莫名不上屏。管理端默认 true、大屏解析 `!== false` 也是 true。
|
||||
func TestParseScheduleValueShowNoteDefault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"缺键", `{"items":[]}`, true},
|
||||
{"显式 true", `{"items":[],"showNote":true}`, true},
|
||||
{"显式 false", `{"items":[],"showNote":false}`, false},
|
||||
{"值为 null", `{"items":[],"showNote":null}`, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := ParseScheduleValue(tc.raw)
|
||||
if cfg == nil {
|
||||
t.Fatalf("%s 应能解析", tc.raw)
|
||||
}
|
||||
if cfg.ShowNote != tc.want {
|
||||
t.Fatalf("%s 的 showNote 应为 %v,得到 %v", tc.raw, tc.want, cfg.ShowNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 序列化出去也必须是显式布尔(前端按布尔读,不给 undefined)。
|
||||
raw, err := json.Marshal(ParseScheduleValue(`{"items":[]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("序列化: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"showNote":true`) {
|
||||
t.Fatalf("缺键解析出的配置序列化时 showNote 应为 true: %s", raw)
|
||||
}
|
||||
|
||||
// showNote 是字符串这种**类型**损坏仍走既有口径「坏 JSON 整个当没有这个功能」:
|
||||
// 整份解析返回 nil(不是静默当开)。类型不对的字段与别的字段一样会解不动结构体。
|
||||
if got := ParseScheduleValue(`{"items":[],"showNote":"no"}`); got != nil {
|
||||
t.Fatalf("类型不对的字段当空,得到 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 读入口**永不报错**:坏 JSON / 被手工改坏的值一律当空,绝不能让大屏整块不显示。
|
||||
// 口径与 record_bonus.levels(rules/settings.go)、ParseLiveEventIDs 一致。
|
||||
func TestParseScheduleValueTolerant(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"",
|
||||
" ",
|
||||
"{items: [",
|
||||
`"日程"`,
|
||||
`{"items":[{"time":"08:30","title":"开幕`,
|
||||
`[1,2,3]`,
|
||||
} {
|
||||
if got := ParseScheduleValue(raw); got != nil {
|
||||
t.Fatalf("坏数据 %q 应当空,得到 %+v", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 结构上不成立的条目 / 覆盖丢掉,认不出的左右与越界宽度按读侧口径归一。
|
||||
// 时间为空的条目**不丢**:写入口与管理端都允许时间留空(先排标题、回头补时间),
|
||||
// 读侧把它丢掉等于静默吞掉一条操作员亲手存下来的日程。
|
||||
cfg := ParseScheduleValue(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` +
|
||||
`{"time":"09:00","title":" "},{"time":"上午","title":"趣味赛","note":"操场"}],` +
|
||||
`"visible":true,"side":"up","widthPercent":999,"showNote":true,` +
|
||||
`"instanceOverrides":{"browser-1":"off","browser-2":"maybe","browser-3":"inherit"}}`)
|
||||
if cfg == nil {
|
||||
t.Fatal("结构合法的部分应读出来")
|
||||
}
|
||||
if len(cfg.Items) != 3 || cfg.Items[0].Title != "开幕式" || cfg.Items[2].Note != "操场" {
|
||||
t.Fatalf("只有标题为空的条目该丢掉: %+v", cfg.Items)
|
||||
}
|
||||
if got := cfg.Items[1]; got.Title != "时间待定" || got.Time != "" {
|
||||
t.Fatalf("时间为空的条目应保留且 time 是空串: %+v", got)
|
||||
}
|
||||
if cfg.Side != ScheduleSideRight || cfg.WidthPercent != MaxScheduleWidthPercent {
|
||||
t.Fatalf("左右应回落 right、宽度应夹到 40: %+v", cfg)
|
||||
}
|
||||
if !cfg.Visible || !cfg.ShowNote {
|
||||
t.Fatalf("开关应原样读出: %+v", cfg)
|
||||
}
|
||||
// 非三态的值丢掉;`inherit` 也丢掉(它就是「没有这条设置」,留着只会白占条数预算)。
|
||||
if len(cfg.InstanceOverrides) != 1 || cfg.InstanceOverrides["browser-1"] != ScheduleOverrideOff {
|
||||
t.Fatalf("非三态与 inherit 的覆盖都该丢掉: %+v", cfg.InstanceOverrides)
|
||||
}
|
||||
|
||||
// 越界的条数只取前 N 条,不报错。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < MaxScheduleItems+1; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(`{"time":"08:30","title":"项"}`)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
if got := ParseScheduleValue(sb.String()); len(got.Items) != MaxScheduleItems {
|
||||
t.Fatalf("条目应截到 %d 条,得到 %d", MaxScheduleItems, len(got.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// 归一函数是读写两侧共用的口径,单独钉住边界(写侧夹取、读侧容错都走它)。
|
||||
func TestNormalizeScheduleSideAndWidth(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"": "right", "right": "right", "left": "left", "LEFT": "left", " Left ": "left",
|
||||
"up": "right", "左右": "right",
|
||||
} {
|
||||
if got := NormalizeScheduleSide(raw); got != want {
|
||||
t.Fatalf("NormalizeScheduleSide(%q) = %q,期望 %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
for raw, want := range map[float64]float64{
|
||||
0: DefaultScheduleWidthPercent, -3: DefaultScheduleWidthPercent,
|
||||
1: MinScheduleWidthPercent, 14.9: MinScheduleWidthPercent,
|
||||
22: 22, 40: 40, 100: MaxScheduleWidthPercent,
|
||||
} {
|
||||
if got := NormalizeScheduleWidth(raw); got != want {
|
||||
t.Fatalf("NormalizeScheduleWidth(%v) = %v,期望 %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 线上形状:JSON 键一律小驼峰(与既有 display.* 键一致),
|
||||
// items 空时是 [] 而不是 null —— 前端按数组解析,null 会当场炸。
|
||||
func TestScheduleConfigJSONShape(t *testing.T) {
|
||||
raw, err := json.Marshal(ScheduleConfig{
|
||||
Items: []ScheduleItem{}, Side: ScheduleSideRight, WidthPercent: 22,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("序列化: %v", err)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
t.Fatalf("解析: %v", err)
|
||||
}
|
||||
for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote"} {
|
||||
if _, ok := top[key]; !ok {
|
||||
t.Fatalf("缺少小驼峰键 %s: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if string(top["items"]) != "[]" {
|
||||
t.Fatalf("空日程应序列化为 []: %s", raw)
|
||||
}
|
||||
// 单屏覆盖可缺:全是 inherit 时不必写出来(写侧只在有内容时才建 map)。
|
||||
if _, ok := top["instanceOverrides"]; ok {
|
||||
t.Fatalf("没有覆盖时不该出现 instanceOverrides: %s", raw)
|
||||
}
|
||||
// 可缺字段:note / done 缺省时不下线。
|
||||
itemRaw, err := json.Marshal(ScheduleItem{Time: "08:30", Title: "开幕式"})
|
||||
if err != nil {
|
||||
t.Fatalf("序列化条目: %v", err)
|
||||
}
|
||||
var item map[string]json.RawMessage
|
||||
if err := json.Unmarshal(itemRaw, &item); err != nil {
|
||||
t.Fatalf("解析条目: %v", err)
|
||||
}
|
||||
if len(item) != 2 {
|
||||
t.Fatalf("缺省字段不该上线,得到 %s", itemRaw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// 一份最小可用的日程配置:含中文、含 note / done 缺省、含单屏覆盖。
|
||||
// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。
|
||||
func schedulePayload() map[string]any {
|
||||
return map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"time": "08:30", "title": "男子 100 米预赛", "note": "检录 08:00", "done": true},
|
||||
{"time": "上午", "title": "开幕式"},
|
||||
{"time": "待定", "title": "闭幕式", "done": false},
|
||||
},
|
||||
"visible": true,
|
||||
"side": "left",
|
||||
"widthPercent": 22,
|
||||
"showNote": true,
|
||||
"instanceOverrides": map[string]any{"browser-1": "on", "browser-2": "off"},
|
||||
}
|
||||
}
|
||||
|
||||
// postSchedule 下发一条 schedule 指令;csrf 只在已登录请求上带(未登录请求没有 CSRF Cookie)。
|
||||
func postSchedule(t *testing.T, env *testEnv, client *http.Client, payload any, csrf bool) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "schedule", "payload": payload}, csrf)
|
||||
}
|
||||
|
||||
// 日程保存:**先转发、再落库**,并留下 settings 变更(大屏靠它 resync 才看得到)。
|
||||
// 落库的 JSON 形状要与前端类型逐字对上:小驼峰键、note/done 可缺、中文原样。
|
||||
func TestDisplayCommandScheduleForwardsThenPersists(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
before := changeLogCount(t, env, "settings", "upsert")
|
||||
|
||||
status, env2 := postSchedule(t, env, client, schedulePayload(), true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("保存日程应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// 转发:大屏要立刻看到侧边栏(热开关),不能只落库等下次快照。
|
||||
if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule {
|
||||
t.Fatalf("日程必须下发给实时层,得到 %+v", sender.sent)
|
||||
}
|
||||
if len(sender.targets) != 1 || sender.targets[0] != nil {
|
||||
t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets)
|
||||
}
|
||||
// 落库:只写 settings 行不会有广播,必须有这条 change_log,且它的载荷要带上日程
|
||||
// ——大屏就是靠这条变更重拉设置、把侧边栏显示出来的。
|
||||
if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
|
||||
t.Fatalf("保存日程应新增 1 条 settings change_log,得到 %d → %d", before, got)
|
||||
}
|
||||
var payload string
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT payload_json FROM change_log WHERE entity_type = 'settings' AND action = 'upsert'
|
||||
ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
|
||||
t.Fatalf("读 change_log 载荷: %v", err)
|
||||
}
|
||||
if !strings.Contains(payload, `"schedule"`) || !strings.Contains(payload, "男子 100 米预赛") {
|
||||
t.Fatalf("settings 变更的载荷应带上整份日程,得到 %s", payload)
|
||||
}
|
||||
|
||||
raw := settingValue(t, env, "display.schedule")
|
||||
var stored map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||||
t.Fatalf("落库值应是 JSON: %v (%s)", err, raw)
|
||||
}
|
||||
for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote", "instanceOverrides"} {
|
||||
if _, ok := stored[key]; !ok {
|
||||
t.Fatalf("落库 JSON 缺少小驼峰键 %s: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if stored["visible"] != true || stored["side"] != "left" || stored["showNote"] != true {
|
||||
t.Fatalf("开关与左右应原样落库: %s", raw)
|
||||
}
|
||||
if stored["widthPercent"].(float64) != 22 {
|
||||
t.Fatalf("宽度应落库为 22: %s", raw)
|
||||
}
|
||||
items := stored["items"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("应落库 3 条日程,得到 %d: %s", len(items), raw)
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
if first["time"] != "08:30" || first["title"] != "男子 100 米预赛" ||
|
||||
first["note"] != "检录 08:00" || first["done"] != true {
|
||||
t.Fatalf("第一条日程应原样落库: %+v", first)
|
||||
}
|
||||
// note / done 是可缺字段:没填的那条不该被补成空串,前端按可选字段解析。
|
||||
second := items[1].(map[string]any)
|
||||
if _, ok := second["note"]; ok {
|
||||
t.Fatalf("没填备注的条目不该带 note 键: %+v", second)
|
||||
}
|
||||
if _, ok := second["done"]; ok {
|
||||
t.Fatalf("未勾选的条目不该带 done 键: %+v", second)
|
||||
}
|
||||
overrides := stored["instanceOverrides"].(map[string]any)
|
||||
if overrides["browser-1"] != "on" || overrides["browser-2"] != "off" {
|
||||
t.Fatalf("单屏覆盖应原样落库: %+v", overrides)
|
||||
}
|
||||
|
||||
audit := waitDisplayAudit(t, env)
|
||||
for _, want := range []string{`"action":"schedule"`, `"matched":1`} {
|
||||
if !strings.Contains(audit, want) {
|
||||
t.Fatalf("审计应含 %s,得到 %s", want, audit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 → 读回一致:管理端与快照走的是同一个 ParseEventSettings,读回来的形状必须与写进去的一样。
|
||||
func TestDisplayCommandScheduleRoundTrip(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
boss, _ := env.client()
|
||||
env.login(boss, "boss", "boss-pass")
|
||||
|
||||
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
|
||||
t.Fatalf("保存日程应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
_, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false)
|
||||
settings := decodeData[map[string]any](t, env2)
|
||||
schedule, ok := settings["display"].(map[string]any)["schedule"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("读回的 settings 应带 display.schedule: %+v", settings["display"])
|
||||
}
|
||||
if schedule["visible"] != true || schedule["side"] != "left" || schedule["showNote"] != true {
|
||||
t.Fatalf("读回的开关 / 左右 / 备注开关应与写进去的一致: %+v", schedule)
|
||||
}
|
||||
if schedule["widthPercent"].(float64) != 22 {
|
||||
t.Fatalf("读回的宽度应为 22: %+v", schedule)
|
||||
}
|
||||
items := schedule["items"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("读回 3 条日程,得到 %d: %+v", len(items), items)
|
||||
}
|
||||
last := items[2].(map[string]any)
|
||||
if last["time"] != "待定" || last["title"] != "闭幕式" {
|
||||
t.Fatalf("手填的自由时间文本(「待定」)应原样读回: %+v", last)
|
||||
}
|
||||
// done 是可缺字段:false 不下线,前端按「没这个键 = 未完成」解析(这也是「当前项」的判据)。
|
||||
if v, ok := last["done"]; ok {
|
||||
t.Fatalf("未完成项不该带 done 键,得到 %+v", v)
|
||||
}
|
||||
if overrides := schedule["instanceOverrides"].(map[string]any); overrides["browser-2"] != "off" {
|
||||
t.Fatalf("单屏覆盖应原样读回: %+v", schedule)
|
||||
}
|
||||
}
|
||||
|
||||
// 上限与非法输入:一律 400 + 中文提示,且**不转发、不落库**。
|
||||
// 静默截断会让操作员以为配置生效了,所以这里必须拒绝而不是存一半。
|
||||
func TestDisplayCommandScheduleRejectsOverLimit(t *testing.T) {
|
||||
items := func(n int) []map[string]any {
|
||||
out := make([]map[string]any, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, map[string]any{"time": "08:30", "title": "第 " + strings.Repeat("x", i%3) + " 项"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
payloadWith := func(mutate func(map[string]any)) map[string]any {
|
||||
p := schedulePayload()
|
||||
mutate(p)
|
||||
return p
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
payload any
|
||||
want string
|
||||
}{
|
||||
{"条目超过 40 条", payloadWith(func(p map[string]any) { p["items"] = items(41) }), "日程条目最多 40 条"},
|
||||
{"标题为空", payloadWith(func(p map[string]any) {
|
||||
p["items"] = []map[string]any{{"time": "08:30", "title": "\n\x00"}}
|
||||
}), "第 1 条日程的标题不能为空"},
|
||||
{"时间超过 20 字", payloadWith(func(p map[string]any) {
|
||||
p["items"] = []map[string]any{{"time": strings.Repeat("时", 21), "title": "开幕式"}}
|
||||
}), "时间最长 20 个字符"},
|
||||
{"标题超过 40 字", payloadWith(func(p map[string]any) {
|
||||
p["items"] = []map[string]any{{"time": "08:30", "title": strings.Repeat("赛", 41)}}
|
||||
}), "标题最长 40 个字符"},
|
||||
{"备注超过 60 字", payloadWith(func(p map[string]any) {
|
||||
p["items"] = []map[string]any{{"time": "08:30", "title": "开幕式", "note": strings.Repeat("备", 61)}}
|
||||
}), "备注最长 60 个字符"},
|
||||
{"第二项越界也要指出是第几条", payloadWith(func(p map[string]any) {
|
||||
p["items"] = []map[string]any{
|
||||
{"time": "08:30", "title": "开幕式"},
|
||||
{"time": "09:00", "title": strings.Repeat("赛", 41)},
|
||||
}
|
||||
}), "第 2 条日程的标题最长 40 个字符"},
|
||||
{"单屏覆盖超过 50 条", payloadWith(func(p map[string]any) {
|
||||
overrides := map[string]any{}
|
||||
for i := 0; i < 51; i++ {
|
||||
overrides["screen-"+string(rune('a'+i%26))+strings.Repeat("x", i/26)] = "on"
|
||||
}
|
||||
p["instanceOverrides"] = overrides
|
||||
}), "单屏覆盖最多 50 条"},
|
||||
{"单屏覆盖取值不是三态", payloadWith(func(p map[string]any) {
|
||||
p["instanceOverrides"] = map[string]any{"browser-1": "maybe"}
|
||||
}), "取值只能是 inherit / on / off"},
|
||||
{"单屏覆盖的实例 id 超长", payloadWith(func(p map[string]any) {
|
||||
p["instanceOverrides"] = map[string]any{strings.Repeat("i", 121): "on"}
|
||||
}), "实例 id 最长 120 个字符"},
|
||||
{"payload 不是对象", []any{1, 2, 3}, "格式不正确"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
before := changeLogCount(t, env, "settings", "upsert")
|
||||
|
||||
status, env2 := postSchedule(t, env, client, tc.payload, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("非法日程应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, tc.want) {
|
||||
t.Fatalf("提示应含 %q(说清哪一项超限),得到 %q", tc.want, env2.Error.Message)
|
||||
}
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("非法日程不该下发(否则大屏先画一帧错的),得到 %+v", sender.sent)
|
||||
}
|
||||
if got := settingOrEmpty(t, env, "display.schedule"); got != "" {
|
||||
t.Fatalf("非法日程不该落库,得到 %q", got)
|
||||
}
|
||||
if got := changeLogCount(t, env, "settings", "upsert"); got != before {
|
||||
t.Fatalf("非法日程不该产生 settings 变更,得到 %d → %d", before, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 时间**允许为空**:管理端的 validateSchedule 只卡时间长度、不卡必填(「先把标题排上、
|
||||
// 时间回头补」是现场的常见流程),服务端必须同口径 —— 否则「校验不过时一个请求都不发」
|
||||
// 这条承诺在这一格上不成立,操作员会先看到请求发出去、再被退一句 400。
|
||||
// 空串 / 全空白 / 只含控制字符都算空,落库就是空串(不是特殊占位,也不是整条丢掉)。
|
||||
func TestDisplayCommandScheduleAllowsEmptyTime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
time string
|
||||
}{
|
||||
{"空串", ""},
|
||||
{"全空白", " "},
|
||||
{"只含控制字符", "\n\t\x00"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := postSchedule(t, env, client, map[string]any{
|
||||
"items": []map[string]any{{"time": tc.time, "title": "闭幕式"}},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("时间为空应保存成功,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule {
|
||||
t.Fatalf("时间为空的日程也要下发给大屏,得到 %+v", sender.sent)
|
||||
}
|
||||
var stored struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
|
||||
t.Fatalf("解析落库值: %v", err)
|
||||
}
|
||||
if len(stored.Items) != 1 {
|
||||
t.Fatalf("条目应保留,得到 %+v", stored.Items)
|
||||
}
|
||||
if got, ok := stored.Items[0]["time"]; !ok || got != "" {
|
||||
t.Fatalf("时间应原样落库为空串(键要在、值要是空串),得到 %+v", stored.Items[0])
|
||||
}
|
||||
if stored.Items[0]["title"] != "闭幕式" {
|
||||
t.Fatalf("标题应原样落库: %+v", stored.Items[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// showNote 缺省 = **true**(备注默认上屏):没带这个字段的客户端不能把备注悄悄关掉。
|
||||
// bool 零值分不出「键没带」与「显式 false」,所以这条用例两种都要钉。
|
||||
func TestDisplayCommandScheduleShowNoteDefaultsTrue(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"不带 showNote", map[string]any{}, true},
|
||||
{"显式 true", map[string]any{"showNote": true}, true},
|
||||
{"显式 false", map[string]any{"showNote": false}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
boss, _ := env.client()
|
||||
env.login(boss, "boss", "boss-pass")
|
||||
|
||||
body := map[string]any{"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}}
|
||||
for k, v := range tc.body {
|
||||
body[k] = v
|
||||
}
|
||||
if status, e := postSchedule(t, env, client, body, true); status != http.StatusOK {
|
||||
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
// 落库的 JSON 里必须是显式的布尔(不吃零值),再看读回的口径。
|
||||
var stored struct {
|
||||
ShowNote *bool `json:"showNote"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
|
||||
t.Fatalf("解析落库值: %v", err)
|
||||
}
|
||||
if stored.ShowNote == nil || *stored.ShowNote != tc.want {
|
||||
t.Fatalf("落库的 showNote 应为 %v,得到 %v", tc.want, stored.ShowNote)
|
||||
}
|
||||
_, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false)
|
||||
schedule := decodeData[map[string]any](t, env2)["display"].(map[string]any)["schedule"].(map[string]any)
|
||||
if schedule["showNote"] != tc.want {
|
||||
t.Fatalf("读回的 showNote 应为 %v,得到 %v", tc.want, schedule["showNote"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 类型不对(不是布尔)按「格式不正确」挡在门外,而不是悄悄当开:
|
||||
// 写入口是人在操作,含糊放过一个坏 payload 只会让现场更难看懂。
|
||||
// (读侧没有出口,遇到这种值就回落 true,见 dto.ScheduleShowNote。)
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
status, env2 := postSchedule(t, env, client, map[string]any{
|
||||
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "showNote": "no",
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("showNote 不是布尔应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "格式不正确") {
|
||||
t.Fatalf("提示应说清是格式问题,得到 %q", env2.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// `inherit` 是「没有这条设置」(缺省就是它):写侧接受但不落库,绝不白占 50 条预算。
|
||||
func TestDisplayCommandScheduleDropsInheritOverrides(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := postSchedule(t, env, client, map[string]any{
|
||||
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}},
|
||||
"instanceOverrides": map[string]any{"browser-1": "inherit", "browser-2": "on"},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("inherit 应被接受(不算非法),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
var stored struct {
|
||||
InstanceOverrides map[string]string `json:"instanceOverrides"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
|
||||
t.Fatalf("解析落库值: %v", err)
|
||||
}
|
||||
if len(stored.InstanceOverrides) != 1 || stored.InstanceOverrides["browser-2"] != "on" {
|
||||
t.Fatalf("只该留下 browser-2=on,得到 %+v", stored.InstanceOverrides)
|
||||
}
|
||||
|
||||
// 全是 inherit 时整块键都不该出现(前端按可选字段解析)。
|
||||
if status, e := postSchedule(t, env, client, map[string]any{
|
||||
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}},
|
||||
"instanceOverrides": map[string]any{"browser-1": "inherit"},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if raw := settingValue(t, env, "display.schedule"); strings.Contains(raw, "instanceOverrides") {
|
||||
t.Fatalf("全是 inherit 时不该落库这个键: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 清洗:控制字符一律剥掉(复用 realtime.CleanUserText),首尾空白去掉;
|
||||
// 归一:widthPercent 夹到 15–40、缺省回落默认宽度,side 认不出时回落 right。
|
||||
func TestDisplayCommandScheduleCleansAndClamps(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := postSchedule(t, env, client, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"time": " 08:30\t", "title": "男子\n100\x00米预赛", "note": "检录\r\n08:00", "done": true},
|
||||
},
|
||||
"visible": true,
|
||||
"side": "middle",
|
||||
"widthPercent": 100,
|
||||
"showNote": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("保存在上限内的日程应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var stored struct {
|
||||
Items []struct {
|
||||
Time string `json:"time"`
|
||||
Title string `json:"title"`
|
||||
Note string `json:"note"`
|
||||
Done bool `json:"done"`
|
||||
} `json:"items"`
|
||||
Side string `json:"side"`
|
||||
WidthPercent float64 `json:"widthPercent"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
|
||||
t.Fatalf("解析落库值: %v", err)
|
||||
}
|
||||
if stored.Items[0].Time != "08:30" || stored.Items[0].Title != "男子100米预赛" ||
|
||||
stored.Items[0].Note != "检录08:00" {
|
||||
t.Fatalf("控制字符应被剥掉、首尾空白应去掉: %+v", stored.Items[0])
|
||||
}
|
||||
if !stored.Items[0].Done {
|
||||
t.Fatalf("已完成应原样保留: %+v", stored.Items[0])
|
||||
}
|
||||
if stored.WidthPercent != 40 {
|
||||
t.Fatalf("线上限 40:widthPercent 100 应夹到 40,得到 %v", stored.WidthPercent)
|
||||
}
|
||||
if stored.Side != "right" {
|
||||
t.Fatalf("认不出的 side 应回落 right,得到 %q", stored.Side)
|
||||
}
|
||||
// 转发的也是归一后的值(否则大屏会先按 100% 画一帧)。
|
||||
if audit := waitDisplayAudit(t, env); !strings.Contains(audit, `"widthPercent":40`) {
|
||||
t.Fatalf("审计里的配置应是归一后的值,得到 %s", audit)
|
||||
}
|
||||
|
||||
// 下上限与缺省。
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
side string
|
||||
width float64
|
||||
}{
|
||||
{"过窄夹到 15", map[string]any{"items": []any{}, "side": "left", "widthPercent": 5}, "left", 15},
|
||||
{"缺省回落 22", map[string]any{"items": []any{}}, "right", 22},
|
||||
{"负数回落 22", map[string]any{"items": []any{}, "widthPercent": -3}, "right", 22},
|
||||
{"大小写不敏感", map[string]any{"items": []any{}, "side": "LEFT"}, "left", 22},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
if status, e := postSchedule(t, env, client, tc.body, true); status != http.StatusOK {
|
||||
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
var got struct {
|
||||
Side string `json:"side"`
|
||||
WidthPercent float64 `json:"widthPercent"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &got); err != nil {
|
||||
t.Fatalf("解析落库值: %v", err)
|
||||
}
|
||||
if got.Side != tc.side || got.WidthPercent != tc.width {
|
||||
t.Fatalf("归一结果应为 side=%s width=%v,得到 side=%s width=%v",
|
||||
tc.side, tc.width, got.Side, got.WidthPercent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 权限:与大屏公告(同一条 display:command 通道)同档 —— 未登录 401、录入员 200、管理员 200。
|
||||
// 两种 action 各发一次,用同一次请求比对状态码,避免「档位被悄悄改动」看不出来。
|
||||
func TestDisplayCommandSchedulePermissionMatchesAnnounce(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender })
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
|
||||
anon, _ := env.client()
|
||||
// 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。
|
||||
scheduleStatus, _ := postSchedule(t, env, anon, schedulePayload(), false)
|
||||
announceStatus, _ := env.do(anon, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "announce", "payload": map[string]any{"text": "决赛推迟"}}, false)
|
||||
if scheduleStatus != announceStatus {
|
||||
t.Fatalf("日程与公告应同档:未登录日程 %d,公告 %d", scheduleStatus, announceStatus)
|
||||
}
|
||||
if scheduleStatus != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录应被挡下(写接口未登录一律 401),得到 %d", scheduleStatus)
|
||||
}
|
||||
|
||||
for _, who := range []struct{ user, pass string }{{"recorder", "recorder-pass"}, {"boss", "boss-pass"}} {
|
||||
client, _ := env.client()
|
||||
env.login(client, who.user, who.pass)
|
||||
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
|
||||
t.Fatalf("%s 保存日程应 200,得到 %d %+v", who.user, status, e.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定点点名的屏全离线:与既有 action 同样的 404,且日程不落库、不产生 settings 变更。
|
||||
func TestDisplayCommandScheduleOfflineTargetReturns404(t *testing.T) {
|
||||
matched := 0
|
||||
sender := &strictDisplaySender{matched: &matched}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
before := changeLogCount(t, env, "settings", "upsert")
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
|
||||
"action": "schedule",
|
||||
"payload": schedulePayload(),
|
||||
"targets": []string{"gone-screen"},
|
||||
}, true)
|
||||
if status != http.StatusNotFound || env2.Error == nil {
|
||||
t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error.Message != "目标大屏已离线,请刷新在线列表" {
|
||||
t.Fatalf("404 消息应与既有 action 一致,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if got := settingOrEmpty(t, env, "display.schedule"); got != "" {
|
||||
t.Fatalf("没命中任何屏时日程不该落库,得到 %q", got)
|
||||
}
|
||||
if got := changeLogCount(t, env, "settings", "upsert"); got != before {
|
||||
t.Fatalf("定向失败不该产生 settings 变更,得到 %d → %d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 读取侧容错:库里那个值被改坏(手工改库 / 写到一半断电)时,settings 照常 200、
|
||||
// display.schedule 当空,大屏按「没有这个功能」处理 —— 一条脏设置绝不能把大屏打不开。
|
||||
func TestSettingsTolerantToBrokenScheduleValue(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "boss", "boss-pass")
|
||||
|
||||
writeRaw := func(raw string) {
|
||||
t.Helper()
|
||||
if _, err := env.ev.Exec(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
"display.schedule", raw); err != nil {
|
||||
t.Fatalf("写入配置: %v", err)
|
||||
}
|
||||
}
|
||||
readSchedule := func() (map[string]any, bool) {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("坏数据下 settings 仍应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
display := decodeData[map[string]any](t, env2)["display"].(map[string]any)
|
||||
v, ok := display["schedule"].(map[string]any)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, raw string }{
|
||||
{"坏 JSON", `{items: [`},
|
||||
{"不是对象", `"日程"`},
|
||||
{"截断的 JSON", `{"items":[{"time":"08:30","title":"开幕`},
|
||||
} {
|
||||
writeRaw(tc.raw)
|
||||
if _, ok := readSchedule(); ok {
|
||||
t.Fatalf("%s:坏数据应读成 null(当空),得到非空值", tc.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 结构上不成立的部分丢掉:**标题**缺失的条目、非三态的覆盖,越界的条数取前 40。
|
||||
// 时间为空的条目是合法的,必须留着(与写入口、管理端同口径),`time` 读成空串。
|
||||
writeRaw(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` +
|
||||
`{"time":"09:00","title":""},{"title":"连 time 键都没有"}],` +
|
||||
`"visible":true,"side":"up","widthPercent":999,"showNote":false,` +
|
||||
`"instanceOverrides":{"browser-1":"on","browser-2":"maybe","":"on"}}`)
|
||||
got, ok := readSchedule()
|
||||
if !ok {
|
||||
t.Fatal("结构合法的部分应照样读出来")
|
||||
}
|
||||
items := got["items"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("只有标题为空的条目该丢掉,应留 3 条,得到 %+v", items)
|
||||
}
|
||||
if empty := items[1].(map[string]any); empty["title"] != "时间待定" || empty["time"] != "" {
|
||||
t.Fatalf("时间为空的条目应保留且 time 读成空串,得到 %+v", empty)
|
||||
}
|
||||
if noKey := items[2].(map[string]any); noKey["title"] != "连 time 键都没有" || noKey["time"] != "" {
|
||||
t.Fatalf("连 time 键都没有的条目也该保留(当空串),得到 %+v", noKey)
|
||||
}
|
||||
if got["side"] != "right" {
|
||||
t.Fatalf("认不出的 side 读侧也应回落 right,得到 %v", got["side"])
|
||||
}
|
||||
if got["widthPercent"].(float64) != 40 {
|
||||
t.Fatalf("越界宽度读侧应夹到 40,得到 %v", got["widthPercent"])
|
||||
}
|
||||
if got["visible"] != true {
|
||||
t.Fatalf("总开关应照样读出来: %+v", got)
|
||||
}
|
||||
overrides := got["instanceOverrides"].(map[string]any)
|
||||
if len(overrides) != 1 || overrides["browser-1"] != "on" {
|
||||
t.Fatalf("非三态与空 id 的覆盖应丢掉,只留 browser-1: %+v", overrides)
|
||||
}
|
||||
|
||||
// 条数越界:读侧只取前 40 条,不报错。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < 41; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(`{"time":"08:30","title":"项"}`)
|
||||
}
|
||||
sb.WriteString(`],"visible":true}`)
|
||||
writeRaw(sb.String())
|
||||
got, ok = readSchedule()
|
||||
if !ok {
|
||||
t.Fatal("条数越界时其余字段仍应读出来")
|
||||
}
|
||||
if got := got["items"].([]any); len(got) != 40 {
|
||||
t.Fatalf("读侧应只取前 40 条,得到 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 未接入实时层时(s.display == nil)日程照样落库:大屏后连上来靠的是 settings + 快照。
|
||||
func TestDisplayCommandSchedulePersistsWithoutRealtime(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
|
||||
t.Fatalf("没有实时层时应落库并 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'display.schedule'`).Scan(&n); err != nil {
|
||||
t.Fatalf("查配置: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("日程应落库,得到 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 漏了 payload(客户端 bug)时给中文 400,而不是把大屏切成一个空日程。
|
||||
func TestDisplayCommandScheduleRequiresPayload(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "schedule"}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("缺 payload 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "缺少 payload") {
|
||||
t.Fatalf("提示应说清缺什么,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("缺 payload 不该下发,得到 %+v", sender.sent)
|
||||
}
|
||||
var v sql.NullString
|
||||
if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.schedule'`).Scan(&v); err == nil {
|
||||
t.Fatalf("缺 payload 不该落库,得到 %q", v.String)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
@@ -318,6 +322,127 @@ func announceFromPayload(payload any) (string, int) {
|
||||
return text, int(seconds)
|
||||
}
|
||||
|
||||
// scheduleFromPayload 把 schedule 指令的 payload 解成整份日程配置,并做完写入口的校验与归一。
|
||||
//
|
||||
// 返回的 error 是给现场看的**中文提示**,调用方按 400 处理。口径:
|
||||
// - 条目数、文本长度、单屏覆盖条数与取值超限一律**拒绝**,不静默截断 ——
|
||||
// 悄悄存一半会让人以为配置已经生效;
|
||||
// - 只有两类按口径归一(夹取 / 回落,没有信息损失):widthPercent 夹到 15–40
|
||||
// (缺省或非正数回落默认宽度)、side 认不出时回落 right。
|
||||
//
|
||||
// 文本一律先过 realtime.CleanUserText(去控制字符 + 去首尾空白,与设备名、大屏别名共用一个收口):
|
||||
// 一条带换行的标题能把大屏那一列顶歪,而服务端是唯一的收口处。清洗后只判**标题**必填
|
||||
// (必填口径与管理端 scheduleEditor.ts 的 validateSchedule 逐字一致,那边时间也只卡长度)。
|
||||
//
|
||||
// 单屏覆盖的键是实例 id,**不**做控制字符剥离:剥完就不再是那台设备的 id,覆盖会静默落空;
|
||||
// 含控制字符的键直接判非法,让现场看见。
|
||||
//
|
||||
// 两处「缺省不等于零值」在这里收口:showNote 没带就是 true(备注默认上屏)、
|
||||
// 值为 inherit 的覆盖不进 map(它就是「没有这条设置」)。
|
||||
func scheduleFromPayload(payload any) (dto.ScheduleConfig, error) {
|
||||
if payload == nil {
|
||||
return dto.ScheduleConfig{}, errors.New("schedule 指令缺少 payload:需要整份日程配置")
|
||||
}
|
||||
// payload 在请求体里是 any,再走一遍 JSON 才能按结构体类型解出来(类型不对在这里就报错)。
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return dto.ScheduleConfig{}, errors.New("日程配置无法解析")
|
||||
}
|
||||
var cfg dto.ScheduleConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return dto.ScheduleConfig{}, errors.New(
|
||||
"日程配置格式不正确:payload 应是一个对象(items / visible / side / widthPercent / showNote / instanceOverrides)")
|
||||
}
|
||||
// showNote 缺省 = true(备注默认上屏):bool 零值分不出「键没带」与「显式 false」,
|
||||
// 这里必须回头看一眼原始 JSON,否则漏字段的客户端会把备注悄悄关掉。
|
||||
showNote := dto.ScheduleShowNote(raw)
|
||||
|
||||
if len(cfg.Items) > dto.MaxScheduleItems {
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("日程条目最多 %d 条(当前 %d 条)",
|
||||
dto.MaxScheduleItems, len(cfg.Items))
|
||||
}
|
||||
items := make([]dto.ScheduleItem, 0, len(cfg.Items))
|
||||
for i, it := range cfg.Items {
|
||||
// 序号按**用户看到的顺序**从 1 数起:提示里说「第 3 条」才能对上编辑面板里那一行。
|
||||
no := i + 1
|
||||
time := realtime.CleanUserText(it.Time)
|
||||
title := realtime.CleanUserText(it.Title)
|
||||
note := realtime.CleanUserText(it.Note)
|
||||
switch {
|
||||
// 时间**允许为空**(管理端 `validateSchedule` 同口径,定稿口径是「时间完全手填」):
|
||||
// 现场常见「先把标题排上、时间回头补」,空的只是不上屏显示时间那一格,不是错误数据。
|
||||
case utf8.RuneCountInString(time) > dto.MaxScheduleTimeChars:
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的时间最长 %d 个字符(当前 %d 个)",
|
||||
no, dto.MaxScheduleTimeChars, utf8.RuneCountInString(time))
|
||||
case title == "":
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题不能为空", no)
|
||||
case utf8.RuneCountInString(title) > dto.MaxScheduleTitleChars:
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题最长 %d 个字符(当前 %d 个)",
|
||||
no, dto.MaxScheduleTitleChars, utf8.RuneCountInString(title))
|
||||
case utf8.RuneCountInString(note) > dto.MaxScheduleNoteChars:
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的备注最长 %d 个字符(当前 %d 个)",
|
||||
no, dto.MaxScheduleNoteChars, utf8.RuneCountInString(note))
|
||||
}
|
||||
// 清空的时间原样存空串(不带 omitempty 以外的替换):大屏看到空串就不画时间那一格。
|
||||
items = append(items, dto.ScheduleItem{Time: time, Title: title, Note: note, Done: it.Done})
|
||||
}
|
||||
|
||||
if len(cfg.InstanceOverrides) > dto.MaxScheduleOverrides {
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖最多 %d 条(当前 %d 条)",
|
||||
dto.MaxScheduleOverrides, len(cfg.InstanceOverrides))
|
||||
}
|
||||
// 按 id 排序后再校验:map 的遍历顺序是随机的,不排序的话「先报哪一条」每次都不一样,
|
||||
// 现场按提示去改的那一条可能下一次就不是它了。
|
||||
var overrides map[string]string
|
||||
if len(cfg.InstanceOverrides) > 0 {
|
||||
ids := make([]string, 0, len(cfg.InstanceOverrides))
|
||||
for id := range cfg.InstanceOverrides {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
overrides = make(map[string]string, len(ids))
|
||||
for _, id := range ids {
|
||||
value := cfg.InstanceOverrides[id]
|
||||
if realtime.CleanUserText(id) != strings.TrimSpace(id) {
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id %q 含有非法字符", id)
|
||||
}
|
||||
key := strings.TrimSpace(id)
|
||||
if key == "" {
|
||||
return dto.ScheduleConfig{}, errors.New("单屏覆盖的实例 id 不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(key) > dto.MaxScheduleOverrideKeyChars {
|
||||
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id 最长 %d 个字符(当前 %d 个)",
|
||||
dto.MaxScheduleOverrideKeyChars, utf8.RuneCountInString(key))
|
||||
}
|
||||
switch value {
|
||||
case dto.ScheduleOverrideInherit, dto.ScheduleOverrideOn, dto.ScheduleOverrideOff:
|
||||
default:
|
||||
return dto.ScheduleConfig{}, fmt.Errorf(
|
||||
"单屏覆盖 %s 的取值只能是 inherit / on / off(当前 %q)", key, value)
|
||||
}
|
||||
// inherit 是「没有这条设置」(缺省就是它):接受但**不落库**,否则它会白占
|
||||
// MaxScheduleOverrides 的条数预算 —— 管理端与大屏解析都把这种键当不存在,
|
||||
// 只有服务端存着的话,攒够 50 条谁都再加不了覆盖。
|
||||
if value == dto.ScheduleOverrideInherit {
|
||||
continue
|
||||
}
|
||||
overrides[key] = value
|
||||
}
|
||||
if len(overrides) == 0 {
|
||||
overrides = nil
|
||||
}
|
||||
}
|
||||
|
||||
return dto.ScheduleConfig{
|
||||
Items: items,
|
||||
Visible: cfg.Visible,
|
||||
Side: dto.NormalizeScheduleSide(cfg.Side),
|
||||
WidthPercent: dto.NormalizeScheduleWidth(cfg.WidthPercent),
|
||||
ShowNote: showNote,
|
||||
InstanceOverrides: overrides,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// displayCommandAudit 是 display.command 的审计载荷:指令内容原样保留(现场按 action 检索的
|
||||
// 既有查询不受影响),另外记上定向目标与实际命中的实例数。
|
||||
type displayCommandAudit struct {
|
||||
@@ -333,6 +458,19 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
||||
if in.Action == "" {
|
||||
return nil, badRequest("action 必填", nil)
|
||||
}
|
||||
// 日程是整份替换,**校验必须排在转发之前**:非法配置一旦先发出去,大屏会先照着一份越界的
|
||||
// 日程画上一帧,然后才被服务端否定。校验通过的对象在这里就定下来,下面只管转发与落库。
|
||||
var schedule dto.ScheduleConfig
|
||||
if in.Action == realtime.CmdSchedule {
|
||||
cfg, err := scheduleFromPayload(in.Payload)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error(), nil)
|
||||
}
|
||||
schedule = cfg
|
||||
// 转发的是**归一后**的配置:客户端把 widthPercent 填成 100 时落库是 40,
|
||||
// 若把原值直接发给大屏,那块屏会先按 100% 画一帧、再被快照纠回来。
|
||||
in.Payload = cfg
|
||||
}
|
||||
matched := 0
|
||||
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
|
||||
// 大屏读到靠的是 settings + change_log,与这一次广播无关。
|
||||
@@ -368,6 +506,12 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
||||
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 日程与公告同一个形状:上面已经把指令转发出去了,这里再把持久值写掉 ——
|
||||
// 只转发不落库的话,大屏刷新 / 重启(或后连上来)就再也看不到当前日程了。
|
||||
case realtime.CmdSchedule:
|
||||
if err := s.saveSchedule(r.Context(), schedule, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
|
||||
// after 里带了这次的具体 action、公告正文、定向目标与命中数。
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -373,6 +374,7 @@ func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
||||
|
||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
||||
// 赛事日程侧边栏走的是同一条路(见下面的 saveSchedule)。
|
||||
const (
|
||||
announceTextKey = "display.announce_text"
|
||||
announceSecondsKey = "display.announce_seconds"
|
||||
@@ -384,17 +386,19 @@ func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int,
|
||||
if seconds <= 0 {
|
||||
seconds = defaultAnnounceSeconds
|
||||
}
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||||
return s.writeSettingValues(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||||
}
|
||||
|
||||
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
|
||||
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, "")
|
||||
return s.writeSettingValues(ctx, actorID, announceTextKey, "")
|
||||
}
|
||||
|
||||
// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
|
||||
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
|
||||
// writeSettingValues 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到变化。
|
||||
//
|
||||
// 名字里不绑具体的键:公告与赛事日程都从这条路落库,事务与 change_log 的写法只此一份。
|
||||
func (s *server) writeSettingValues(ctx context.Context, actorID int64, kv ...string) error {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
@@ -422,6 +426,24 @@ func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...str
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 赛事日程侧边栏 ----------
|
||||
|
||||
// saveSchedule 把整份日程配置落库到 display.schedule(读侧见 dto.ParseScheduleValue,
|
||||
// 线上形状见 dto.ScheduleConfig)。
|
||||
//
|
||||
// 整份替换:服务端不做局部合并 —— 客户端手上就有整份配置,合并只会让两边不一致。
|
||||
// 校验与清洗在 scheduleFromPayload 里做完,这里只管序列化与落库。
|
||||
//
|
||||
// ⚠️ 每次保存都会往 change_log 落**整份设置快照**、并让全场大屏做一次全量 resync,
|
||||
// 所以这是**低频**动作(开赛前配一次、现场偶尔勾一下「已完成」),别拿它做高频轮询。
|
||||
func (s *server) saveSchedule(ctx context.Context, cfg dto.ScheduleConfig, actorID int64) error {
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return s.writeSettingValues(ctx, actorID, dto.SettingKeySchedule, string(raw))
|
||||
}
|
||||
|
||||
// ---------- standings ----------
|
||||
|
||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource", "schedule"})
|
||||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
||||
|
||||
|
||||
@@ -986,6 +986,87 @@ func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事日程(schedule)是与公告同路的整份配置:action 在协议清单里、payload 原样落到线上。
|
||||
// 只把 action 加进白名单而漏了 payload 的形状,大屏就会收到一个空的侧边栏。
|
||||
func TestSendCommandScheduleCarriesWholeConfig(t *testing.T) {
|
||||
if !ValidDisplayAction(CmdSchedule) {
|
||||
t.Fatalf("%s 应在协议清单里", CmdSchedule)
|
||||
}
|
||||
env := newTestEnv(t)
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
b.recvEvent(EventSnapshot)
|
||||
|
||||
payload := map[string]any{
|
||||
"items": []map[string]any{{"time": "08:30", "title": "开幕式", "done": true}},
|
||||
"visible": true,
|
||||
"side": "left",
|
||||
"widthPercent": float64(22),
|
||||
"showNote": true,
|
||||
"instanceOverrides": map[string]any{"screen-b": "off"},
|
||||
}
|
||||
cmd, err := NewDisplayCommand(CmdSchedule, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 schedule: %v", err)
|
||||
}
|
||||
matched, err := env.hub.SendCommandToInstances(nil, cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("广播日程: %v", err)
|
||||
}
|
||||
if matched != 2 {
|
||||
t.Fatalf("广播应命中 2 个在线实例,得到 %d", matched)
|
||||
}
|
||||
for _, c := range []*wsClient{a, b} {
|
||||
got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand))
|
||||
if got.Action != CmdSchedule {
|
||||
t.Fatalf("实例应收到 schedule,得到 %s", got.Action)
|
||||
}
|
||||
// 大屏只认 payload 里的键:整份配置(含单屏覆盖)必须逐字透传,服务端不做局部合并。
|
||||
var round map[string]any
|
||||
if err := json.Unmarshal(got.Payload, &round); err != nil {
|
||||
t.Fatalf("解析 payload: %v (%s)", err, got.Payload)
|
||||
}
|
||||
if round["side"] != "left" || round["visible"] != true || round["widthPercent"] != float64(22) {
|
||||
t.Fatalf("配置应原样透传,得到 %+v", round)
|
||||
}
|
||||
items := round["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["title"] != "开幕式" {
|
||||
t.Fatalf("条目应原样透传,得到 %+v", items)
|
||||
}
|
||||
if overrides := round["instanceOverrides"].(map[string]any); overrides["screen-b"] != "off" {
|
||||
t.Fatalf("单屏覆盖应原样透传,得到 %+v", overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// schedule 也要走定向通道(与公告同一套 targets 语义)。
|
||||
func TestSendCommandScheduleTargetsSingleInstance(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
b.recvEvent(EventSnapshot)
|
||||
|
||||
cmd, err := NewDisplayCommand(CmdSchedule, map[string]any{"visible": false, "items": []any{}})
|
||||
if err != nil {
|
||||
t.Fatalf("构造 schedule: %v", err)
|
||||
}
|
||||
matched, err := env.hub.SendCommandToInstances([]string{"screen-a"}, cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("定向日程: %v", err)
|
||||
}
|
||||
if matched != 1 {
|
||||
t.Fatalf("命中实例数应为 1,得到 %d", matched)
|
||||
}
|
||||
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdSchedule {
|
||||
t.Fatalf("被点名的实例应收到 schedule,得到 %s", got.Action)
|
||||
}
|
||||
if ev, _, ok := b.recvWithin(200 * time.Millisecond); ok {
|
||||
t.Fatalf("未被点名的实例不该收到消息,得到 %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。
|
||||
func TestDisplayInstancesReportScreen(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -64,6 +64,9 @@ const (
|
||||
CmdHideDetail = "hide-detail"
|
||||
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
|
||||
CmdAnnounceClear = "announce_clear"
|
||||
// CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig):
|
||||
// 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。
|
||||
CmdSchedule = "schedule"
|
||||
)
|
||||
|
||||
var displayActions = map[string]bool{
|
||||
@@ -75,6 +78,7 @@ var displayActions = map[string]bool{
|
||||
CmdAnnounceClear: true,
|
||||
CmdShowDetail: true,
|
||||
CmdHideDetail: true,
|
||||
CmdSchedule: true,
|
||||
}
|
||||
|
||||
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
|
||||
|
||||
@@ -420,6 +420,42 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事日程侧边栏随 settings 下发(不用改快照:display.* 不是 admin-only 字段)。
|
||||
// 库里那个值被改坏时快照必须照常构建、schedule 当空 —— 一条脏设置不能让大屏连不上。
|
||||
func TestSnapshotScheduleToleratesBrokenValue(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
mustExec(t, db, `INSERT INTO settings(key, value, updated_at) VALUES('display.schedule', ?, datetime('now'))`,
|
||||
`{"items":[{"time":"08:30","title":"开幕式","done":true}],`+
|
||||
`"visible":true,"side":"left","widthPercent":22,"showNote":true}`)
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
got := snap.Settings.Display.Schedule
|
||||
if got == nil || !got.Visible || got.Side != dto.ScheduleSideLeft || len(got.Items) != 1 {
|
||||
t.Fatalf("日程应随快照下发: %+v", got)
|
||||
}
|
||||
// 线上 JSON 用大屏解析的键名(前端与 REST 同一份形状)。
|
||||
raw, err := json.Marshal(snap.Settings)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化 settings: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"schedule":{"items":[{"time":"08:30","title":"开幕式","done":true}]`) {
|
||||
t.Fatalf("快照里日程的形状不对: %s", raw)
|
||||
}
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = '{坏 JSON' WHERE key = 'display.schedule'`)
|
||||
snap, err = env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("坏数据下快照仍应构建成功: %v", err)
|
||||
}
|
||||
if snap.Settings.Display.Schedule != nil {
|
||||
t.Fatalf("坏数据应读成 null,得到 %+v", snap.Settings.Display.Schedule)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
|
||||
Reference in new issue
Block a user