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()) }