主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 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。
534 lines
21 KiB
Go
534 lines
21 KiB
Go
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())
|
||
}
|