Files
Scoreboard/server/internal/httpapi/handlers_results.go
T
AskaEth b5f40642ff feat: 公告持久化(底栏常驻)+ 团体可单独报名 + 榜单系数开关
公告:
- 服务端把当前公告落库到 settings(display.announce_text / announce_seconds),
  作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留)
- announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计
- realtime 协议登记 announce_clear(补上,使清除能即时推到大屏)
- 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏,
  直到被替换或清除;新增公告状态机(可测)与底栏取值优先级
  推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态

团体报名:
- EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致
  「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了)
- 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过),
  抽出共享的 entryRowLabel 供两处复用

榜单口径:
- 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight
  数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」,
  保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段,
  故做成独立卡片并标注全局,不塞进模板表单

自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
2026-09-10 11:06:58 +08:00

262 lines
8.4 KiB
Go

package httpapi
import (
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"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)
}
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 publishPayload
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))
})
}
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))
})
}
// ---------- 积分与排名 ----------
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))
}
// ---------- 大屏控制与运维 ----------
// cmdAnnounceClear 是清空当前公告的指令;实时层的协议清单(realtime.ValidDisplayAction)还没登记它,
// 因此转发前要先看实时层认不认,见 displayCommandHandler。
const cmdAnnounceClear = "announce_clear"
// 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)
}
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)
}
// 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收),
// 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil {
return nil, internal(err)
}
}
// 公告要落库:只转发不落库的话,全屏推送结束(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 cmdAnnounceClear:
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
return nil, err
}
}
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
// after 里已经带了这次的具体 action 与公告正文。
s.audit(r.Context(), "display.command", "display", 0, nil, in)
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")
return s.listAuditLogs(r.Context(), page, size, entityType, userID)
}
func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.backup(r.Context())
}