主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 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。
348 lines
13 KiB
Go
348 lines
13 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
|
||
)
|
||
|
||
// waitClosed 等待关闭回调触发;没触发就报错。
|
||
func waitClosed(t *testing.T, done <-chan struct{}) {
|
||
t.Helper()
|
||
select {
|
||
case <-done:
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("等待关闭回调超时")
|
||
}
|
||
}
|
||
|
||
func assertNotClosed(t *testing.T, done <-chan struct{}) {
|
||
t.Helper()
|
||
select {
|
||
case <-done:
|
||
t.Fatal("鉴权失败时不应触发关闭")
|
||
default:
|
||
}
|
||
}
|
||
|
||
// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。
|
||
func waitAuditAction(t *testing.T, env *testEnv, action string) int {
|
||
t.Helper()
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for {
|
||
var count int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil {
|
||
t.Fatalf("查询审计: %v", err)
|
||
}
|
||
if count > 0 {
|
||
return count
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("等待审计 %s 超时", action)
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) {
|
||
t.Helper()
|
||
done := make(chan struct{})
|
||
var once sync.Once
|
||
return newTestEnvWith(t, func(o *httpapi.Options) {
|
||
o.Shutdown = func() { once.Do(func() { close(done) }) }
|
||
}), done
|
||
}
|
||
|
||
// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。
|
||
func TestShutdownByAdminSession(t *testing.T) {
|
||
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "")
|
||
env, done := newShutdownEnv(t)
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
client, _ := env.client()
|
||
|
||
// 未登录:401,不触发关闭。
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized ||
|
||
env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||
t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||
}
|
||
assertNotClosed(t, done)
|
||
|
||
// recorder:403,不触发关闭。
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
rec, _ := env.client()
|
||
env.login(rec, "recorder", "recorder-pass")
|
||
if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden ||
|
||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
||
t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
|
||
}
|
||
assertNotClosed(t, done)
|
||
|
||
// admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。
|
||
env.login(client, "admin", "admin-pass")
|
||
status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true)
|
||
if status != http.StatusAccepted {
|
||
t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw)
|
||
}
|
||
if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` {
|
||
t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got)
|
||
}
|
||
waitClosed(t, done)
|
||
|
||
if count := waitAuditAction(t, env, "system.shutdown"); count != 1 {
|
||
t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count)
|
||
}
|
||
var after string
|
||
if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil {
|
||
t.Fatalf("读取关闭审计: %v", err)
|
||
}
|
||
if !strings.Contains(after, `"session"`) {
|
||
t.Fatalf("关闭审计应标明凭据来源,得到 %s", after)
|
||
}
|
||
}
|
||
|
||
// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。
|
||
func TestShutdownByToken(t *testing.T) {
|
||
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token")
|
||
env, done := newShutdownEnv(t)
|
||
client, _ := env.client()
|
||
|
||
if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
|
||
map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden {
|
||
t.Fatalf("错误 token 应 403,得到 %d", status)
|
||
}
|
||
assertNotClosed(t, done)
|
||
|
||
status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
|
||
map[string]string{"X-Shutdown-Token": "s3cret-token"})
|
||
if status != http.StatusAccepted {
|
||
t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error)
|
||
}
|
||
waitClosed(t, done)
|
||
|
||
// 没注入关闭回调时应拒绝受理,避免谎报成功。
|
||
bare := newTestEnv(t)
|
||
bareClient, _ := bare.client()
|
||
if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false,
|
||
map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable {
|
||
t.Fatalf("未接入关闭回调应 503,得到 %d", status)
|
||
}
|
||
}
|
||
|
||
// GET /api/system/about:版本包里的 buildTime 原样返回;未注入时是空串(前端据此显示「未注入」)。
|
||
// 顺带锁住权限:guest 依旧读不到(该端点靠它取内部署名)。
|
||
func TestSystemAboutReturnsBuildTime(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
|
||
guest, _ := env.client()
|
||
if status, _ := env.do(guest, http.MethodGet, "/api/system/about", nil, false); status != http.StatusForbidden {
|
||
t.Fatalf("guest 读 about 应 403,得到 %d", status)
|
||
}
|
||
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
// 模拟 ldflags 注入:直接改包级变量,用例结束后恢复,避免影响其他用例。
|
||
original := version.BuildTime
|
||
t.Cleanup(func() { version.BuildTime = original })
|
||
|
||
version.BuildTime = "2026-09-10 08:50:12 +08:00"
|
||
status, env2 := env.do(client, http.MethodGet, "/api/system/about", nil, false)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("recorder 读 about 应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
var about map[string]any
|
||
if err := json.Unmarshal(env2.Data, &about); err != nil {
|
||
t.Fatalf("解析 about: %v (%s)", err, env2.Data)
|
||
}
|
||
if about["buildTime"] != "2026-09-10 08:50:12 +08:00" {
|
||
t.Fatalf("buildTime 应原样返回注入值,得到 %#v", about["buildTime"])
|
||
}
|
||
if _, ok := about["internalSignature"]; !ok {
|
||
t.Fatalf("about 仍应带 internalSignature: %v", about)
|
||
}
|
||
|
||
// 未注入时字段仍在、值为空串,前端才能稳定判断「未注入」而不是解析失败。
|
||
version.BuildTime = ""
|
||
_, env2 = env.do(client, http.MethodGet, "/api/system/about", nil, false)
|
||
if err := json.Unmarshal(env2.Data, &about); err != nil {
|
||
t.Fatalf("解析 about: %v", err)
|
||
}
|
||
if got, ok := about["buildTime"]; !ok || got != "" {
|
||
t.Fatalf("未注入时 buildTime 应为空串,得到 %#v(存在=%v)", got, ok)
|
||
}
|
||
}
|
||
|
||
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
|
||
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
|
||
guest, _ := env.client()
|
||
if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden {
|
||
t.Fatalf("guest 读规则开关应 403,得到 %d", status)
|
||
}
|
||
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
var switches map[string]any
|
||
if err := json.Unmarshal(env2.Data, &switches); err != nil {
|
||
t.Fatalf("解析规则开关: %v (%s)", err, env2.Data)
|
||
}
|
||
want := []string{
|
||
"recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired",
|
||
"medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals",
|
||
}
|
||
if len(switches) != len(want) {
|
||
t.Fatalf("只应返回布尔开关,得到 %v", switches)
|
||
}
|
||
for _, key := range want {
|
||
v, ok := switches[key]
|
||
if !ok {
|
||
t.Fatalf("缺少开关 %s: %v", key, switches)
|
||
}
|
||
if _, isBool := v.(bool); !isBool {
|
||
t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v)
|
||
}
|
||
}
|
||
// 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。
|
||
if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false ||
|
||
switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true ||
|
||
switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true {
|
||
t.Fatalf("默认开关取值不符: %v", switches)
|
||
}
|
||
|
||
// 规则导入/配置改动后开关随之变化。
|
||
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`)
|
||
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
|
||
_, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
|
||
if err := json.Unmarshal(env2.Data, &switches); err != nil {
|
||
t.Fatalf("解析规则开关: %v", err)
|
||
}
|
||
if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true {
|
||
t.Fatalf("开关应随 settings 变化: %v", switches)
|
||
}
|
||
}
|
||
|
||
// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。
|
||
func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.seed(t)
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
client, _ := env.client()
|
||
env.login(client, "admin", "admin-pass")
|
||
|
||
_, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||
var settings map[string]any
|
||
if err := json.Unmarshal(env2.Data, &settings); err != nil {
|
||
t.Fatalf("解析 settings: %v", err)
|
||
}
|
||
assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"})
|
||
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", "schedule"})
|
||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
||
|
||
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||
var rows []map[string]any
|
||
if err := json.Unmarshal(env2.Data, &rows); err != nil {
|
||
t.Fatalf("解析 standings: %v", err)
|
||
}
|
||
if len(rows) == 0 {
|
||
t.Fatal("榜单不应为空")
|
||
}
|
||
assertKeys(t, "standings[0]", rows[0], []string{
|
||
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
|
||
"rank", "medalRank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
|
||
})
|
||
}
|
||
|
||
func asMap(t *testing.T, v any) map[string]any {
|
||
t.Helper()
|
||
m, ok := v.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("期望对象,得到 %T", v)
|
||
}
|
||
return m
|
||
}
|
||
|
||
func assertKeys(t *testing.T, name string, got map[string]any, want []string) {
|
||
t.Helper()
|
||
if len(got) != len(want) {
|
||
t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got)
|
||
}
|
||
for _, key := range want {
|
||
if _, ok := got[key]; !ok {
|
||
t.Fatalf("%s 缺少字段 %s:%v", name, key, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
type settingsStandings struct {
|
||
Standings struct {
|
||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||
} `json:"standings"`
|
||
}
|
||
|
||
// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。
|
||
func TestSettingsShowIndividualMedals(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
client, _ := env.client()
|
||
env.login(client, "admin", "admin-pass")
|
||
|
||
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("读配置应 200,得到 %d", status)
|
||
}
|
||
settings := decodeData[settingsStandings](t, env2)
|
||
if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
|
||
t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings)
|
||
}
|
||
|
||
// 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。
|
||
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||
"standings": map[string]any{"showIndividualBoard": false},
|
||
}, true)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
settings = decodeData[settingsStandings](t, env2)
|
||
if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
|
||
t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings)
|
||
}
|
||
|
||
// 两个开关可以独立配置:board=true + medals=false。
|
||
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||
"standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false},
|
||
}, true)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
settings = decodeData[settingsStandings](t, env2)
|
||
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
|
||
t.Fatalf("两个开关应可独立配置: %+v", settings.Standings)
|
||
}
|
||
|
||
// 落库并复读。
|
||
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||
settings = decodeData[settingsStandings](t, env2)
|
||
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
|
||
t.Fatalf("复读配置不符: %+v", settings.Standings)
|
||
}
|
||
}
|