Files
AskaEth 9c52dc4b38 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。
2026-09-17 20:04:50 +08:00

534 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"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"
)
// ---------- 成绩 ----------
func (s *server) listResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state != "" && state != string(domain.ResultStateDraft) && state != string(domain.ResultStatePublished) {
return nil, badRequest("state 只能是 draft/published", state)
}
page, size := pageQuery(r)
return s.listResultsPage(r.Context(), eventID, state, page, size)
}
func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in saveDraftPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
if len(in.Results) == 0 {
return nil, badRequest("results 不能为空", nil)
}
// 计分引擎不读 points_override,落库只会造成「设置成功但毫无效果」。
// 这里直接拒绝并指向正确做法(积分调整),不做静默丢弃。
for i, it := range in.Results {
if it.PointsOverride != nil {
return nil, badRequest("pointsOverride 已不再支持:该值不会被计分引擎读取,请改用「积分调整」为该单位/运动员加减分", i)
}
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
saved, err := s.upsertDraft(r.Context(), in.EventID, middlewareUserID(r), in.Results)
if err != nil {
return nil, err
}
s.audit(r.Context(), "result.draft.save", "event", in.EventID, nil, saved)
return saved, nil
})
}
func (s *server) publishHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishResultsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.publishResults(r.Context(), in, middlewareUserID(r))
})
}
func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.unpublishResults(r.Context(), in, middlewareUserID(r))
})
}
// clearResultsHandler 是「清空当前组 / 批次成绩」(POST /api/results/clear)。
//
// 与 publish / unpublish 同档权限(recorder 及以上 + CSRF):现场就是同一批人在录成绩。
// 用 decodeJSONPresence 是为了分清「请求体里没有 heat」(清整个组别)与
// 「heat 是 null」(只清未分批的行)——两者只差一个键,动作范围差一整组。
func (s *server) clearResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in clearResultsPayload
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.clearResults(r.Context(), in, keys.has("heat"), middlewareUserID(r))
})
}
// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。
func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.settleResults(r.Context(), in, middlewareUserID(r))
})
}
// settlementStatusHandler 返回未结算的 (小项, 组别) 清单,供录入页提示与「立即结算」入口。
func (s *server) settlementStatusHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
if eventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
categoryID, _ := int64Query(r, "categoryId")
return s.settlementStatus(r.Context(), eventID, categoryID)
}
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in voidResultPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.voidResult(r.Context(), id, in.Reason, middlewareUserID(r))
})
}
// reopenResultHandler 把一条成绩退回草稿,供录入员直接修改后重新发布本组。
func (s *server) reopenResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in reopenResultPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.reopenResult(r.Context(), id, in.Reason, middlewareUserID(r))
})
}
// ---------- 积分与排名 ----------
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.standingsSnapshot(r.Context(), s.ev)
}
func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(r.Context(), s.ev)
if err != nil {
return nil, err
}
// 展示字段以全量单位表为准(含停用单位),否则停用单位的运动员会显示空白单位名。
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
genders := make(map[int64]*string, len(in.Athletes))
for _, a := range in.Athletes {
if a.Gender != "" {
g := a.Gender
genders[a.ID] = &g
}
}
rows := domain.BuildAthleteStandings(in)
out := make([]athleteStandingDTO, 0, len(rows))
for _, row := range rows {
item := athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
Gender: genders[row.AthleteID],
TeamID: row.TeamID,
TeamCode: row.TeamCode,
TeamName: row.TeamName,
TotalPoints: row.Points,
Gold: row.Gold,
Silver: row.Silver,
Bronze: row.Bronze,
}
if t, ok := teams[row.TeamID]; ok {
item.TeamCode = t.Code
item.TeamName = t.Name
item.ShortName = t.ShortName
// 单位表里可能存的是空串,下发前统一收敛成 null,与总积分榜口径一致。
item.LogoPath = nilIfEmpty(strValue(t.LogoPath))
}
out = append(out, item)
}
return out, nil
}
func (s *server) breakdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.teamBreakdown(r.Context(), id)
}
// ---------- 人工调整 ----------
func (s *server) listAdjustmentsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
page, size := pageQuery(r)
return s.listAdjustments(r.Context(), teamID, page, size)
}
func (s *server) createAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in adjustmentInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.createAdjustment(r.Context(), in, middlewareUserID(r))
})
}
func (s *server) voidAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in voidAdjustmentPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.voidAdjustment(r.Context(), id, middlewareUserID(r), in.Reason)
})
}
// ---------- 纪录 ----------
func (s *server) listRecordsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
return s.listRecords(r.Context(), eventID)
}
func (s *server) createRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in recordBreakInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
created, err := s.createRecordBreak(r.Context(), in, middlewareUserID(r))
if err != nil {
return nil, err
}
s.audit(r.Context(), "record_break.create", "record_break", created.ID, nil, created)
return created, nil
})
}
func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.approveRecordBreak(r.Context(), id, middlewareUserID(r))
}
// listRecordBreaksHandler 列出破纪录登记(最新在前),可选按审核状态过滤。
// 状态拼错时明确报 400:静默回空列表会让现场以为「登记全没了」。
func (s *server) listRecordBreaksHandler(w http.ResponseWriter, r *http.Request) (any, error) {
state := strings.TrimSpace(r.URL.Query().Get("state"))
switch state {
case "", "pending", "approved", "rejected":
default:
return nil, badRequest("state 只能是 pending/approved/rejected", state)
}
return s.listRecordBreaks(r.Context(), state)
}
// rejectRecordBreakHandler 驳回 / 作废一条破纪录登记;理由必填并落到 note。
func (s *server) rejectRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in rejectRecordBreakPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.rejectRecordBreak(r.Context(), id, middlewareUserID(r), in.Reason)
})
}
// createRecordHandler 手工登记一条历史纪录(纪录库的「新增」)。
func (s *server) createRecordHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in recordEntryInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.createRecordEntry(r.Context(), in, middlewareUserID(r))
})
}
// deleteRecordHandler 删除一条纪录(纪录库的「删除」)。
func (s *server) deleteRecordHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
return s.deleteRecordEntry(r.Context(), id, middlewareUserID(r))
}
// ---------- 大屏控制与运维 ----------
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
// payload 由 JSON 解出,缺失的字段按零值处理,由落库侧补默认时长。
func announceFromPayload(payload any) (string, int) {
fields, ok := payload.(map[string]any)
if !ok {
return "", 0
}
text, _ := fields["text"].(string)
seconds, _ := fields["seconds"].(float64)
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 {
displayCommandInput
Matched int `json:"matched"`
}
func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in displayCommandInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
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,与这一次广播无关。
if s.display != nil {
n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets)
if err != nil {
// 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。
// 生产路径上这个错由 realtime.NewDisplayCommand 返回(裸错误,没带哨兵),
// 所以除了 errors.Is 还要按协议清单判一次;两类失败不能混成同一个 5xx:
// 只有传输层真失败(写 socket 失败等)才走 internal。
if errors.Is(err, realtime.ErrUnknownAction) || !realtime.ValidDisplayAction(in.Action) {
return nil, badRequest(fmt.Sprintf("未知的大屏指令 %q", in.Action), in.Action)
}
return nil, internal(err)
}
matched = n
// 点了名却一块屏都没命中(已离线或 id 写错)时明确报错,别让现场以为指令发出去了。
if len(in.Targets) > 0 && n == 0 {
return nil, notFound("目标大屏已离线,请刷新在线列表")
}
}
// 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」,
// 新连上的大屏也拿不到当前公告。清空公告走同一份持久值(写空串),
// 否则大屏重连后会把已经撤掉的公告再显示一遍。
actorID := middlewareUserID(r)
switch in.Action {
case realtime.CmdAnnounce:
text, seconds := announceFromPayload(in.Payload)
if err := s.saveAnnouncement(r.Context(), text, seconds, actorID); err != nil {
return nil, err
}
case realtime.CmdAnnounceClear:
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、公告正文、定向目标与命中数。
s.audit(r.Context(), "display.command", "display", 0, nil,
displayCommandAudit{displayCommandInput: in, Matched: matched})
return nil, nil
}
func (s *server) listAuditLogsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
page, size := pageQuery(r)
entityType := strings.TrimSpace(r.URL.Query().Get("entityType"))
userID, _ := int64Query(r, "userId")
action := strings.TrimSpace(r.URL.Query().Get("action"))
return s.listAuditLogs(r.Context(), page, size, entityType, userID, action)
}
func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.backup(r.Context())
}