feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
package httpapi_test
import (
"database/sql"
"encoding/json"
"net/http"
"testing"
)
type seedIDs struct {
teamA, teamB int64
athleteA, athB int64
sport, event int64
category, entryA int64
entryB int64
}
func (e *testEnv) seed(t *testing.T) seedIDs {
t.Helper()
var ids seedIDs
ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB)
ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
ids.event = e.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event)
var profileID int64
if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID)
ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA)
ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB)
return ids
}
func (e *testEnv) exec(t *testing.T, query string, args ...any) {
t.Helper()
if _, err := e.ev.Exec(query, args...); err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
}
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
t.Helper()
res, err := e.ev.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}
// 未登录 = guest:赛事数据可读,系统配置一律 403。
func TestGuestReadsEventDataButNotConfig(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
client, _ := env.client()
if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK)
}
if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK)
}
for _, path := range []string{"/api/settings", "/api/audit-logs"} {
status, env2 := env.do(client, http.MethodGet, path, nil, false)
if status != http.StatusForbidden {
t.Fatalf("guest 读 %s 应 403,得到 %d", path, status)
}
if env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error)
}
}
// 未登录写操作要求先登录。
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
}
// recorder 拥有录入权限,但没有系统配置权限。
func TestRecorderForbiddenOnAdminRoutes(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"name": "改名"},
}, true)
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读审计应 403,得到 %d", status)
}
// recorder 的合法写操作仍然可用。
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 建单位应 200,得到 %d %+v", status, env2.Error)
}
}
// 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。
func TestLoginCreatesSession(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var count int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil {
t.Fatalf("查询会话: %v", err)
}
if count != 1 {
t.Fatalf("登录后应有 1 条会话,得到 %d", count)
}
status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d", status)
}
me := decodeData[map[string]any](t, env2)
if me["username"] != "recorder" || me["role"] != "recorder" {
t.Fatalf("me 返回身份不对: %+v", me)
}
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false)
me = decodeData[map[string]any](t, env2)
if status != http.StatusOK || me["role"] != "guest" {
t.Fatalf("登出后应为 guest,得到 %d %+v", status, me)
}
}
// 首次启动创建默认管理员并打印随机初始密码。
func TestEnsureDefaultAdmin(t *testing.T) {
env := newTestEnv(t)
username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context())
if err != nil {
t.Fatalf("创建默认管理员: %v", err)
}
if !created || username == "" || len(password) < 12 {
t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password))
}
client, _ := env.client()
env.login(client, username, password)
}
// 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。
func TestPublishWritesLedgerAndAudit(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
results := decodeData[[]map[string]any](t, env2)
if len(results) != 2 {
t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results))
}
for _, r := range results {
if r["state"] != "published" {
t.Fatalf("成绩应为 published: %+v", r)
}
}
// 流水:A 10 分、B 8 分。
rows, err := env.ev.Query(
`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
points := map[int64]float64{}
for rows.Next() {
var team int64
var p float64
if err := rows.Scan(&team, &p); err != nil {
t.Fatalf("扫描流水: %v", err)
}
points[team] = p
}
if points[ids.teamA] != 10 || points[ids.teamB] != 8 {
t.Fatalf("流水分值不对: %+v", points)
}
// 积分榜:A 第一。
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 {
t.Fatalf("积分榜不对: %+v", standings)
}
if standings[0]["gold"].(float64) != 1 {
t.Fatalf("A 应记 1 金: %+v", standings[0])
}
// 审计:操作人、IP、request_id、before/after 都要有。
var (
action string
auditUser sql.NullInt64
ip, requestID sql.NullString
before, after sql.NullString
)
err = env.ev.QueryRow(
`SELECT action, user_id, ip, request_id, before_json, after_json
FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`).
Scan(&action, &auditUser, &ip, &requestID, &before, &after)
if err != nil {
t.Fatalf("查询审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser)
}
if !ip.Valid || ip.String == "" {
t.Fatalf("审计应记录 IP")
}
if !requestID.Valid || requestID.String == "" {
t.Fatalf("审计应记录 request_id")
}
if !before.Valid || !after.Valid {
t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after)
}
var parsed []map[string]any
if err := json.Unmarshal([]byte(after.String), &parsed); err != nil {
t.Fatalf("审计 after_json 应为成绩数组: %v", err)
}
}
// 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400;
// 同一个 X-Request-Id 重复提交应回放首次结果。
func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"}
status, first := env.do(client, http.MethodPost, "/api/teams", body, true)
if status != http.StatusOK || !first.OK {
t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error)
}
headers := map[string]string{"X-Request-Id": "replay-key-1"}
status, second := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers)
if status != http.StatusOK {
t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status)
}
status, replay := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers)
if status != http.StatusOK || string(replay.Data) != string(second.Data) {
t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data)
}
var teams int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil {
t.Fatalf("统计单位: %v", err)
}
if teams != 2 {
t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams)
}
}
// 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。
func TestPublishIdempotentReplay(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
body := map[string]any{"eventId": ids.event, "requestId": "publish-same"}
_, first := env.do(client, http.MethodPost, "/api/results/publish", body, true)
_, second := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if string(first.Data) != string(second.Data) {
t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data)
}
var ledgerRows int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil {
t.Fatalf("统计流水: %v", err)
}
if ledgerRows != 2 {
t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows)
}
}
+331
View File
@@ -0,0 +1,331 @@
package httpapi
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。
// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。
type teamDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
LogoPath *string `json:"logoPath"`
Color *string `json:"color"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
}
type athleteDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Gender *string `json:"gender"`
TeamID int64 `json:"teamId"`
EmployeeNo *string `json:"employeeNo"`
Active bool `json:"active"`
}
type sportDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
DefaultProfileID *int64 `json:"defaultProfileId"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
}
type categoryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Name string `json:"name"`
Multiplier float64 `json:"multiplier"`
Gender *string `json:"gender"`
AgeMin *int `json:"ageMin"`
AgeMax *int `json:"ageMax"`
SortOrder int `json:"sortOrder"`
}
type channelDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope string `json:"scope"`
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
}
type eventDTO struct {
ID int64 `json:"id"`
SportID int64 `json:"sportId"`
Code string `json:"code"`
Name string `json:"name"`
EventType string `json:"eventType"`
ResultMode string `json:"resultMode"`
ResultType string `json:"resultType"`
Compare string `json:"compare"`
RecordEligible bool `json:"recordEligible"`
Scored bool `json:"scored"`
Multiplier float64 `json:"multiplier"`
Status string `json:"status"`
ScheduledAt *string `json:"scheduledAt"`
Venue *string `json:"venue"`
SortOrder int `json:"sortOrder"`
Remark *string `json:"remark"`
Categories []categoryDTO `json:"categories,omitempty"`
Channels []channelDTO `json:"channels,omitempty"`
}
type profileRuleDTO struct {
Rank int `json:"rank"`
Points float64 `json:"points"`
}
type profileDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TieMode string `json:"tieMode"`
RecordBonusMode string `json:"recordBonusMode"`
AwardRanks int `json:"awardRanks"`
LastPlaceZero bool `json:"lastPlaceZero"`
Rules []profileRuleDTO `json:"rules"`
}
type entryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"`
AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"`
Seed *string `json:"seed"`
Status string `json:"status"`
Remark *string `json:"remark"`
AthleteName *string `json:"athleteName,omitempty"`
TeamCode string `json:"teamCode,omitempty"`
TeamName string `json:"teamName,omitempty"`
ShortName *string `json:"shortName,omitempty"`
CategoryName string `json:"categoryName,omitempty"`
}
type entryWithResultDTO struct {
entryDTO
Result *resultDTO `json:"result"`
}
type resultDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit string `json:"unit"`
IsTie bool `json:"isTie"`
PointsOverride *float64 `json:"pointsOverride"`
Status string `json:"status"`
State string `json:"state"`
RecordedBy *int64 `json:"recordedBy"`
RecordedAt *string `json:"recordedAt"`
VerifiedBy *int64 `json:"verifiedBy"`
VerifiedAt *string `json:"verifiedAt"`
Note *string `json:"note"`
Points *float64 `json:"points,omitempty"`
}
type standingRowDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
Color *string `json:"color"`
LogoPath *string `json:"logoPath"`
Rank int `json:"rank"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
MedalTotal int `json:"medalTotal"`
TotalPoints float64 `json:"totalPoints"`
RankChange *int `json:"rankChange"`
}
type athleteStandingDTO struct {
Rank int `json:"rank"`
AthleteID int64 `json:"athleteId"`
AthleteName string `json:"athleteName"`
Gender *string `json:"gender"`
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
TotalPoints float64 `json:"totalPoints"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
}
type breakdownItemDTO struct {
LedgerID int64 `json:"ledgerId"`
SourceType string `json:"sourceType"`
EventID *int64 `json:"eventId"`
EventCode *string `json:"eventCode"`
EventName *string `json:"eventName"`
CategoryName *string `json:"categoryName"`
AthleteID *int64 `json:"athleteId"`
AthleteName *string `json:"athleteName"`
Rank *int `json:"rank"`
ResultText *string `json:"resultText"`
Points float64 `json:"points"`
CreatedAt string `json:"createdAt"`
}
type teamBreakdownDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
TotalPoints float64 `json:"totalPoints"`
Items []breakdownItemDTO `json:"items"`
}
type adjustmentDTO struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
Category string `json:"category"`
Reason string `json:"reason"`
EventID *int64 `json:"eventId"`
Voided bool `json:"voided"`
CreatedBy *int64 `json:"createdBy"`
CreatedAt string `json:"createdAt"`
}
type recordEntryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
HolderName *string `json:"holderName"`
HolderTeamID *int64 `json:"holderTeamId"`
AchievedAt *string `json:"achievedAt"`
IsCurrent bool `json:"isCurrent"`
Source string `json:"source"`
}
type recordBreakDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
BonusPoints float64 `json:"bonusPoints"`
State string `json:"state"`
ApprovedBy *int64 `json:"approvedBy"`
ApprovedAt *string `json:"approvedAt"`
}
type auditLogDTO struct {
ID int64 `json:"id"`
UserID *int64 `json:"userId"`
Action string `json:"action"`
EntityType string `json:"entityType"`
EntityID *int64 `json:"entityId"`
BeforeJSON *string `json:"beforeJson"`
AfterJSON *string `json:"afterJson"`
IP *string `json:"ip"`
RequestID *string `json:"requestId"`
CreatedAt string `json:"createdAt"`
}
type currentUserDTO struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
LastLoginAt *string `json:"lastLoginAt"`
MustChangePassword bool `json:"mustChangePassword"`
}
type backupResultDTO struct {
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
}
// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。
type eventSettingsDTO struct {
Event eventSettingsEvent `json:"event"`
Display eventSettingsDisplay `json:"display"`
Standings eventSettingsStandings `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval bool `json:"requireApproval"`
}
type eventSettingsEvent struct {
Name string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
type eventSettingsDisplay struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSecond int `json:"rotateSeconds"`
}
type eventSettingsStandings struct {
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
RankingMode string `json:"rankingMode"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
}
// ---------- domain → DTO ----------
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
out := make([]standingRowDTO, 0, len(rows))
for _, r := range rows {
t := teams[r.TeamID]
dto := standingRowDTO{
TeamID: r.TeamID,
TeamCode: r.Code,
TeamName: r.Name,
ShortName: nilIfEmpty(r.ShortName),
Rank: r.Rank,
Gold: r.Gold,
Silver: r.Silver,
Bronze: r.Bronze,
MedalTotal: r.Medals,
TotalPoints: r.Points,
RankChange: nil,
}
if t.ID != 0 {
dto.Color = t.Color
dto.LogoPath = t.LogoPath
if dto.TeamCode == "" {
dto.TeamCode = t.Code
}
if dto.TeamName == "" {
dto.TeamName = t.Name
}
if dto.ShortName == nil {
dto.ShortName = t.ShortName
}
}
out = append(out, dto)
}
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+150
View File
@@ -0,0 +1,150 @@
package httpapi
import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip))
mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip))
mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups))
}
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
func (s *server) streamDownload(w http.ResponseWriter, filename, contentType string, write func(io.Writer) error) (any, error) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filename))
if err := write(w); err != nil {
s.logger.Error("导出写出失败", "file", filename, "err", err)
}
return nil, errHandled
}
func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadStandingsWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteStandingsXLSX(out, wb)
})
}
func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteEventXLSX(out, wb)
})
}
func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAllWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAllXLSX(out, wb)
})
}
func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteTeamDetailXLSX(out, wb)
})
}
func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
dir := filepath.Join(s.dataDir, "exports")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{}); err != nil {
return nil, internal(err)
}
defer func() { _ = os.Remove(dst) }()
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.zip"))
http.ServeFile(w, r, dst)
return nil, errHandled
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少上传字段 file", nil)
}
defer func() { _ = file.Close() }()
tmpDir, err := os.MkdirTemp("", "scoreboard-import-")
if err != nil {
return nil, internal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmp := filepath.Join(tmpDir, "project.zip")
out, err := os.Create(tmp)
if err != nil {
return nil, internal(err)
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
return nil, internal(err)
}
if err := out.Close(); err != nil {
return nil, internal(err)
}
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
})
if err != nil {
return nil, badRequest(err.Error(), nil)
}
return res, nil
}
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
return nil, internal(err)
}
return items, nil
}
+652
View File
@@ -0,0 +1,652 @@
package httpapi
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- 认证 ----------
func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
var in loginInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if strings.TrimSpace(in.Username) == "" || in.Password == "" {
return nil, badRequest("username 与 password 必填", nil)
}
ip := middleware.ClientIP(r)
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
switch {
case err == auth.ErrRateLimited:
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
case err != nil:
return nil, internal(err)
}
s.auth.SetSessionCookie(w, token)
csrf, err := auth.NewCSRFToken()
if err != nil {
return nil, internal(err)
}
s.auth.SetCSRFCookie(w, csrf)
s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username})
return userDTO(u), nil
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
return nil, nil
}
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
return userDTO(u), nil
}
func userDTO(u *auth.User) currentUserDTO {
dto := currentUserDTO{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role.String(),
MustChangePassword: u.MustChangePassword,
}
if u.Role == auth.RoleGuest {
dto.Username = "guest"
dto.DisplayName = "访客"
}
if u.LastLoginAt != "" {
v := u.LastLoginAt
dto.LastLoginAt = &v
}
return dto
}
// ---------- settings ----------
func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.getSettings(r.Context())
}
func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in settingsPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getSettings(r.Context())
if err != nil {
return nil, err
}
updated, err := s.updateSettings(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "settings.update", "settings", 0, before, updated)
return updated, nil
})
}
// ---------- teams ----------
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listTeams(r.Context())
}
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getTeam(r.Context(), id)
}
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createTeam(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.create", "team", created.ID, nil, created)
return created, nil
}
func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateTeam(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.update", "team", id, before, updated)
return updated, nil
}
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteTeam(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "team.delete", "team", id, before, nil)
return nil
}
// ---------- sports ----------
func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listSports(r.Context())
}
func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getSport(r.Context(), id)
}
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createSport(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created)
return created, nil
}
func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateSport(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.update", "sport", id, before, updated)
return updated, nil
}
func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteSport(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "sport.delete", "sport", id, before, nil)
return nil
}
// ---------- events ----------
func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
sportID, _ := int64Query(r, "sportId")
return s.listEvents(r.Context(), sportID)
}
func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getEvent(r.Context(), id)
}
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createEvent(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.create", "event", created.ID, nil, created)
return created, nil
}
func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEvent(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.update", "event", id, before, updated)
return updated, nil
}
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteEvent(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "event.delete", "event", id, before, nil)
return nil
}
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listEntriesWithResults(r.Context(), id)
}
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listCategories(r.Context(), id)
}
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in categoriesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listCategories(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
return updated, nil
}
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listChannels(r.Context(), id)
}
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in channelsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listChannels(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
return updated, nil
}
// ---------- scoring profiles ----------
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listProfiles(r.Context())
}
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getProfile(r.Context(), id)
}
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createProfile(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
return created, nil
}
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
return updated, nil
}
// ---------- athletes ----------
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
return s.listAthletes(r.Context(), teamID)
}
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getAthlete(r.Context(), id)
}
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createAthlete(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
return created, nil
}
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateAthlete(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
return updated, nil
}
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteAthlete(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil
}
// ---------- 报名表导入 ----------
type entryImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", err.Error())
}
defer func() { _ = file.Close() }()
rows, err := readTable(filepath.Ext(header.Filename), file)
if err != nil {
return nil, err
}
result, err := s.importEntries(r.Context(), rows)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
return result, nil
}
func readTable(ext string, file io.Reader) ([][]string, error) {
switch strings.ToLower(ext) {
case ".xlsx":
f, err := excelize.OpenReader(file)
if err != nil {
return nil, badRequest("无法解析 xlsx", err.Error())
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, badRequest("xlsx 没有工作表", nil)
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return nil, badRequest("读取 xlsx 失败", err.Error())
}
return rows, nil
default:
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
return nil, badRequest("无法解析 CSV", err.Error())
}
return rows, nil
}
}
// importColumns 是报名表必须包含的表头(大小写不敏感)。
var importColumns = []string{
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
}
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
result := entryImportResult{Errors: []string{}}
if len(rows) < 2 {
return result, badRequest("报名表为空", nil)
}
index := make(map[string]int, len(rows[0]))
for i, h := range rows[0] {
index[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, col := range importColumns {
if _, ok := index[col]; !ok {
return result, badRequest("报名表缺少列 "+col, nil)
}
}
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
i := index[name]
if i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
eventCode := cell("event_code")
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
continue
}
var categoryID int64
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
continue
}
}
var athleteID *int64
if athleteName != "" {
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
athleteID = &id
}
}
created, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Status: orDefault(cell("status"), "registered"),
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
case created:
result.Created++
default:
result.Updated++
}
}
return result, nil
}
func splitNames(raw string) []string {
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
+222
View File
@@ -0,0 +1,222 @@
package httpapi
import (
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成绩 ----------
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) {
in, err := s.loadStandingsInput(r.Context())
if err != nil {
return nil, err
}
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
}
func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(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 {
out = append(out, athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
Gender: genders[row.AthleteID],
TeamID: row.TeamID,
TeamCode: row.TeamCode,
TeamName: row.TeamName,
ShortName: nilIfEmpty(row.TeamName),
TotalPoints: row.Points,
Gold: row.Gold,
Silver: row.Silver,
Bronze: row.Bronze,
})
}
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))
}
// ---------- 大屏控制与运维 ----------
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)
}
if s.display != nil {
if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil {
return nil, internal(err)
}
}
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())
}
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// errHandled 表示响应已经由 handler 自己写出(例如幂等回放),s.handle 不再重复写。
var errHandled = errors.New("httpapi: response already written")
// idempotencyStore 缓存写操作的首次响应(见 devdocs/05 第 5 节:重复提交返回首次结果)。
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
// 进程重启后客户端重试会重新执行,属于可接受的降级。
type idempotencyStore struct {
mu sync.Mutex
entries map[string]idempotentResponse
ttl time.Duration
}
type idempotentResponse struct {
body []byte
expires time.Time
}
func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
}
func (s *idempotencyStore) get(key string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[key]
if !ok || time.Now().After(e.expires) {
delete(s.entries, key)
return nil, false
}
return e.body, true
}
func (s *idempotencyStore) put(key string, body []byte) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if len(s.entries) > 4096 {
for k, e := range s.entries {
if now.After(e.expires) {
delete(s.entries, k)
}
}
}
s.entries[key] = idempotentResponse{body: body, expires: now.Add(s.ttl)}
}
// idempotent 以 requestId 为键执行写操作;重复请求直接回放首次响应。
// 返回值恒为 errHandled 或真正的错误——调用方写成 `return nil, s.idempotent(...)`。
func (s *server) idempotent(w http.ResponseWriter, r *http.Request, requestID string, fn func() (any, error)) error {
if requestID == "" || s.idem == nil {
data, err := fn()
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
return errHandled
}
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return errHandled
}
data, err := fn()
if err != nil {
return err
}
body, err := json.Marshal(map[string]any{"ok": true, "data": data})
if err != nil {
return internal(err)
}
s.idem.put(key, body)
writeCached(w, body)
return errHandled
}
// cached 给所有写路由加一层基于 X-Request-Id 的幂等缓存:
// handler 自己写响应,这里负责在成功后留存响应体、重复请求时回放。
func (s *server) cached(inner http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get(middleware.HeaderRequestID)
if requestID == "" || s.idem == nil {
inner.ServeHTTP(w, r)
return
}
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return
}
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(cw, r)
if cw.status >= 200 && cw.status < 300 && cw.body.Len() > 0 {
s.idem.put(key, cw.body.Bytes())
}
}
}
func writeCached(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Idempotent-Replay", "1")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
type captureWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
}
func (w *captureWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *captureWriter) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
+108
View File
@@ -0,0 +1,108 @@
package httpapi
// 本文件是各写接口的请求体契约;字段名与 web/src/shared/types.ts 对齐。
type loginInput struct {
Username string `json:"username"`
Password string `json:"password"`
RequestID string `json:"requestId"`
}
type saveDraftPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
Results []draftResultInput `json:"results"`
RequestID string `json:"requestId"`
}
type draftResultInput struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText *string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit *string `json:"unit"`
IsTie bool `json:"isTie"`
Status string `json:"status"`
PointsOverride *float64 `json:"pointsOverride"`
Note *string `json:"note"`
}
type publishPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
RequestID string `json:"requestId"`
}
type voidResultPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type adjustmentInput struct {
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
Category string `json:"category"`
Reason string `json:"reason"`
EventID *int64 `json:"eventId"`
RequestID string `json:"requestId"`
}
type recordBreakInput struct {
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
RequestID string `json:"requestId"`
}
type displayCommandInput struct {
Action string `json:"action"`
Payload any `json:"payload"`
RequestID string `json:"requestId"`
}
type voidAdjustmentPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type settingsPatch struct {
Event *settingsEventPatch `json:"event"`
Display *settingsDisplayPatch `json:"display"`
Standings *settingsStandingsPatch `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval *bool `json:"requireApproval"`
RequestID string `json:"requestId"`
}
type settingsEventPatch struct {
Name *string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
type settingsDisplayPatch struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title *string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSeconds *float64 `json:"rotateSeconds"`
}
type settingsStandingsPatch struct {
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
RankingMode *string `json:"rankingMode"`
ShowIndividualBoard *bool `json:"showIndividualBoard"`
}
type categoriesPayload struct {
Categories []categoryDTO `json:"categories"`
}
type channelsPayload struct {
Channels []channelDTO `json:"channels"`
}
+582
View File
@@ -0,0 +1,582 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
// 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。
func strPtr(ns sql.NullString) *string {
if !ns.Valid {
return nil
}
v := ns.String
return &v
}
func i64Ptr(ni sql.NullInt64) *int64 {
if !ni.Valid {
return nil
}
v := ni.Int64
return &v
}
func intPtr(ni sql.NullInt64) *int {
if !ni.Valid {
return nil
}
v := int(ni.Int64)
return &v
}
func f64Ptr(nf sql.NullFloat64) *float64 {
if !nf.Valid {
return nil
}
v := nf.Float64
return &v
}
func nullStr(p *string) any {
if p == nil || *p == "" {
return nil
}
return *p
}
func nullI64(p *int64) any {
if p == nil {
return nil
}
return *p
}
func nullInt(p *int) any {
if p == nil {
return nil
}
return *p
}
func nullF64(p *float64) any {
if p == nil {
return nil
}
return *p
}
func lastInsertID(res sql.Result) (int64, error) {
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("获取自增 id: %w", err)
}
return id, nil
}
// requireChanges 把「影响行数为 0」翻译成 404。
func requireChanges(res sql.Result, message string) error {
n, err := res.RowsAffected()
if err != nil {
return internal(err)
}
if n == 0 {
return notFound(message)
}
return nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed")
}
func isForeignKeyViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
}
func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) {
var one int
err := db.QueryRowContext(ctx, query, args...).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, err
}
return true, nil
}
// ---------- teams ----------
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active`
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
var (
t teamDTO
shortName, logoPath, color sql.NullString
)
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active)
t.ShortName = strPtr(shortName)
t.LogoPath = strPtr(logoPath)
t.Color = strPtr(color)
return t, err
}
func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]teamDTO, 0)
for rows.Next() {
t, err := scanTeam(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
list, err := s.listTeams(ctx)
if err != nil {
return nil, err
}
out := make(map[int64]teamDTO, len(list))
for _, t := range list {
out[t.ID] = t
}
return out, nil
}
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
t, err := scanTeam(s.ev.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return teamDTO{}, notFound("单位不存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
return t, nil
}
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
if isUniqueViolation(err) {
return teamDTO{}, conflict("单位编码已存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return teamDTO{}, internal(err)
}
return s.getTeam(ctx, id)
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
if isUniqueViolation(err) {
return teamDTO{}, conflict("单位编码已存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
if err := requireChanges(res, "单位不存在"); err != nil {
return teamDTO{}, err
}
return s.getTeam(ctx, id)
}
func (s *server) deleteTeam(ctx context.Context, id int64) error {
// 业务数据软删除:置 active=0,保留历史成绩与流水。
res, err := s.ev.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "单位不存在")
}
// ---------- athletes ----------
const athleteColumns = `id, name, gender, team_id, employee_no, active`
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
var (
a athleteDTO
gender, empNo sql.NullString
teamID sql.NullInt64
)
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active)
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
if teamID.Valid {
a.TeamID = teamID.Int64
}
return a, err
}
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
query := `SELECT ` + athleteColumns + ` FROM athletes`
args := []any{}
if teamID > 0 {
query += ` WHERE team_id = ?`
args = append(args, teamID)
}
query += ` ORDER BY name`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]athleteDTO, 0)
for rows.Next() {
a, err := scanAthlete(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return athleteDTO{}, notFound("运动员不存在")
}
if err != nil {
return athleteDTO{}, internal(err)
}
return a, nil
}
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active)
if isForeignKeyViolation(err) {
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
return athleteDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return athleteDTO{}, internal(err)
}
return s.getAthlete(ctx, id)
}
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id)
if isForeignKeyViolation(err) {
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
return athleteDTO{}, internal(err)
}
if err := requireChanges(res, "运动员不存在"); err != nil {
return athleteDTO{}, err
}
return s.getAthlete(ctx, id)
}
func (s *server) deleteAthlete(ctx context.Context, id int64) error {
res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "运动员不存在")
}
// ---------- sports ----------
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) {
var (
sp sportDTO
shortName sql.NullString
defaultProfileID sql.NullInt64
)
err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active)
sp.ShortName = strPtr(shortName)
sp.DefaultProfileID = i64Ptr(defaultProfileID)
return sp, err
}
func (s *server) listSports(ctx context.Context) ([]sportDTO, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]sportDTO, 0)
for rows.Next() {
sp, err := scanSport(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, sp)
}
return out, rows.Err()
}
func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return sportDTO{}, notFound("大项不存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
return sp, nil
}
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return sportDTO{}, internal(err)
}
return s.getSport(ctx, id)
}
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
if err := requireChanges(res, "大项不存在"); err != nil {
return sportDTO{}, err
}
return s.getSport(ctx, id)
}
func (s *server) deleteSport(ctx context.Context, id int64) error {
// 大项有子项目时不允许软删,避免项目挂在不可见的大项下。
has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("大项下仍有小项,不能删除")
}
res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "大项不存在")
}
// ---------- scoring profiles ----------
func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]profileDTO, 0)
for rows.Next() {
var p profileDTO
if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil {
return nil, internal(err)
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
for i := range out {
rules, err := s.profileRules(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].Rules = rules
}
return out, nil
}
func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]profileRuleDTO, 0)
for rows.Next() {
var r profileRuleDTO
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
return nil, internal(err)
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
var p profileDTO
err := s.ev.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return profileDTO{}, notFound("积分模板不存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
rules, err := s.profileRules(ctx, p.ID)
if err != nil {
return profileDTO{}, err
}
p.Rules = rules
return p, nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return profileDTO{}, internal(err)
}
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
return profileDTO{}, err
}
if err := tx.Commit(); err != nil {
return profileDTO{}, internal(err)
}
return s.getProfile(ctx, id)
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?,
award_ranks = ?, last_place_zero = ?, updated_at = datetime('now')
WHERE id = ?`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
if err := requireChanges(res, "积分模板不存在"); err != nil {
return profileDTO{}, err
}
if in.Rules != nil {
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
return profileDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return profileDTO{}, internal(err)
}
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
for _, r := range rules {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
profileID, r.Rank, r.Points); err != nil {
return internal(err)
}
}
return nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
+449
View File
@@ -0,0 +1,449 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"strings"
)
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
e eventDTO
scheduledAt, venue, note sql.NullString
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note)
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
return e, err
}
func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) {
query := `SELECT ` + eventColumns + ` FROM events`
args := []any{}
if sportID > 0 {
query += ` WHERE sport_id = ?`
args = append(args, sportID)
}
query += ` ORDER BY sort_order, code`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]eventDTO, 0)
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
e, err := scanEvent(s.ev.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
if err != nil {
return eventDTO{}, internal(err)
}
cats, err := s.listCategories(ctx, id)
if err != nil {
return eventDTO{}, err
}
chs, err := s.listChannels(ctx, id)
if err != nil {
return eventDTO{}, err
}
e.Categories = cats
e.Channels = chs
return e, nil
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark))
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
return s.getEvent(ctx, id)
}
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("小项已有报名数据,不能删除")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
`DELETE FROM events WHERE id = ?`,
} {
if _, err := tx.ExecContext(ctx, stmt, id); err != nil {
return internal(err)
}
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
func multiplierOrOne(v float64) float64 {
if v <= 0 {
return 1
}
return v
}
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]categoryDTO, 0)
for rows.Next() {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
out = append(out, c)
}
return out, rows.Err()
}
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
// 否则外键会把历史报名项一并带走。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
WHERE c.event_id = ? LIMIT 1`, eventID)
if err != nil {
return nil, internal(err)
}
if has {
return nil, conflict("该项目已有报名数据,不能整体替换组别")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if strings.TrimSpace(c.Name) == "" {
return nil, badRequest("组别 name 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("组别名称重复:" + c.Name)
}
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listCategories(ctx, eventID)
}
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]channelDTO, 0)
for rows.Next() {
var c channelDTO
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
return nil, internal(err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if c.Scope != "athlete" && c.Scope != "team" {
return nil, badRequest("通道 scope 只能是 athlete/team", i)
}
switch c.Basis {
case "result", "manual", "aggregate":
default:
return nil, badRequest("通道 basis 非法", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
if isForeignKeyViolation(err) {
return nil, badRequest("profileId 不存在", i)
}
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listChannels(ctx, eventID)
}
// ---------- entries ----------
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var (
e entryDTO
athleteID sql.NullInt64
memberNames sql.NullString
bib, seed, rem sql.NullString
athleteName sql.NullString
teamCode sql.NullString
teamName sql.NullString
shortName sql.NullString
categoryName sql.NullString
)
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
return e, err
}
e.AthleteID = i64Ptr(athleteID)
e.MemberNames = parseNames(memberNames)
e.Bib = strPtr(bib)
e.Seed = strPtr(seed)
e.Remark = strPtr(rem)
e.AthleteName = strPtr(athleteName)
e.TeamCode = teamCode.String
e.TeamName = teamName.String
e.ShortName = strPtr(shortName)
e.CategoryName = categoryName.String
return e, nil
}
const entrySelect = `SELECT ` + entryColumns + `
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
JOIN event_categories c ON c.id = e.category_id`
func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) {
rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]entryDTO, 0)
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。
func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) {
entries, err := s.listEntries(ctx, eventID)
if err != nil {
return nil, err
}
results, err := s.resultsByEvent(ctx, eventID)
if err != nil {
return nil, err
}
byEntry := make(map[int64]resultDTO, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
out := make([]entryWithResultDTO, 0, len(entries))
for _, e := range entries {
item := entryWithResultDTO{entryDTO: e}
if r, ok := byEntry[e.ID]; ok {
cp := r
item.Result = &cp
}
out = append(out, item)
}
return out, nil
}
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return entryDTO{}, notFound("报名项不存在")
}
if err != nil {
return entryDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark))
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, id)
}
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`,
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
if err := requireChanges(res, "报名项不存在"); err != nil {
return entryDTO{}, err
}
return s.getEntry(ctx, id)
}
func (s *server) deleteEntry(ctx context.Context, id int64) error {
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "报名项不存在")
}
+52
View File
@@ -0,0 +1,52 @@
package httpapi
import (
"context"
"database/sql"
"errors"
)
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
var id int64
if err := s.ev.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
var existing int64
var err error
if in.AthleteID != nil {
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing)
} else {
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
in.CategoryID, in.TeamID).Scan(&existing)
}
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil {
return false, err
}
return true, nil
case err != nil:
return false, err
default:
if _, err := s.ev.ExecContext(ctx,
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`,
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed),
orDefault(in.Status, "registered"), existing); err != nil {
return false, err
}
return false, nil
}
}
+760
View File
@@ -0,0 +1,760 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- settings ----------
func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) {
rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, internal(err)
}
out[k] = v
}
return out, rows.Err()
}
func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return eventSettingsDTO{}, err
}
var out eventSettingsDTO
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
out.Event.Host = nilIfEmpty(values["event.organizer"])
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
out.Display.Background = nilIfEmpty(values["display.background"])
out.Display.Logo = nilIfEmpty(values["display.logo"])
if out.Display.Logo == nil {
out.Display.Logo = out.Event.LogoPath
}
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
if out.Display.Subtitle == nil {
out.Display.Subtitle = out.Event.Subtitle
}
out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
out.Standings.ShowIndividualBoard = boolDefault(
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
out.RequireApproval = boolDefault(values["results.review_required"], false)
if code := values["scoring.default_profile"]; code != "" {
var id int64
err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
case err != nil:
return eventSettingsDTO{}, internal(err)
default:
out.DefaultProfileID = &id
}
}
return out, nil
}
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventSettingsDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
set := func(key, value string) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
key, value)
if err != nil {
return internal(err)
}
return nil
}
setPtr := func(key string, v *string) error {
if v == nil {
return nil
}
return set(key, *v)
}
if p := patch.Event; p != nil {
if err := setPtr("event.name", p.Name); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.organizer", p.Host); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
}
}
if p := patch.Display; p != nil {
if err := setPtr("display.background", p.Background); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.logo", p.Logo); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.title", p.Title); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
}
if p.RotateSeconds != nil {
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
}
}
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
}
}
if err := setPtr("standings.mode", p.RankingMode); err != nil {
return eventSettingsDTO{}, err
}
if p.ShowIndividualBoard != nil {
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
return eventSettingsDTO{}, err
}
}
}
if patch.DefaultProfileID != nil {
code := ""
if *patch.DefaultProfileID != 0 {
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil)
}
return eventSettingsDTO{}, internal(err)
}
}
if err := set("scoring.default_profile", code); err != nil {
return eventSettingsDTO{}, err
}
}
if patch.RequireApproval != nil {
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
return eventSettingsDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return eventSettingsDTO{}, internal(err)
}
return s.getSettings(ctx)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func atoiDefault(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return def
}
return n
}
func boolDefault(v string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return def
}
}
// ---------- standings ----------
func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) {
teams, err := s.domainTeams(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
athletes, err := s.domainAthletes(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
ledger, err := s.activeLedger(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
ranked, err := s.buildRanked(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return domain.StandingsInput{}, err
}
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
exclude := !boolDefault(values["standings.medal_counts_team_events"], true)
return domain.StandingsInput{
Teams: teams,
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Team, 0)
for rows.Next() {
var (
t domain.Team
shortName sql.NullString
color sql.NullString
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil {
return nil, internal(err)
}
t.ShortName = shortName.String
t.Color = color.String
out = append(out, t)
}
return out, rows.Err()
}
func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Athlete, 0)
for rows.Next() {
var (
a domain.Athlete
gender sql.NullString
teamID sql.NullInt64
)
if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil {
return nil, internal(err)
}
a.Gender = gender.String
a.TeamID = teamID.Int64
out = append(out, a)
}
return out, rows.Err()
}
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。
func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RankedResult, 0)
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, internal(err)
}
rr.Rank = rank
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
}
return out, rows.Err()
}
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
team, err := s.getTeam(ctx, teamID)
if err != nil {
return teamBreakdownDTO{}, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
FROM score_ledger l
LEFT JOIN events ev ON ev.id = l.event_id
LEFT JOIN entries e ON e.id = l.entry_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN results r ON r.id = l.result_id
WHERE l.team_id = ? AND l.voided = 0
ORDER BY l.id DESC`, teamID)
if err != nil {
return teamBreakdownDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
eventCode, eventName sql.NullString
categoryName, athleteName sql.NullString
rank sql.NullInt64
resultText sql.NullString
)
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
return teamBreakdownDTO{}, internal(err)
}
item.EventID = i64Ptr(eventID)
item.EventCode = strPtr(eventCode)
item.EventName = strPtr(eventName)
item.CategoryName = strPtr(categoryName)
item.AthleteID = i64Ptr(athleteID)
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
return out, rows.Err()
}
// ---------- records ----------
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
holder_name, holder_team_id, achieved_at, is_current, source
FROM records WHERE is_current = 1`
args := []any{}
if eventID > 0 {
query += ` AND event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY event_id, level`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]recordEntryDTO, 0)
for rows.Next() {
var (
r recordEntryDTO
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return nil, internal(err)
}
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) {
if in.EventID == 0 || in.EntryID == 0 || in.Level == "" {
return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil)
}
switch in.Level {
case "meet", "city", "province", "national":
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText)
if isForeignKeyViolation(err) {
return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil)
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return recordBreakDTO{}, internal(err)
}
return s.getRecordBreak(ctx, id)
}
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var (
b recordBreakDTO
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
FROM record_breaks WHERE id = ?`, id).
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
b.ApprovedBy = i64Ptr(approvedBy)
b.ApprovedAt = strPtr(approvedAt)
return b, nil
}
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordBreakDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var state string
var eventID int64
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
return recordBreakDTO{}, internal(err)
}
if state != "pending" {
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return recordBreakDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
return recordBreakDTO{}, err
}
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordBreakDTO{}, internal(err)
}
s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"})
return s.getRecordBreak(ctx, id)
}
// ---------- adjustments ----------
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if teamID > 0 {
where = append(where, "team_id = ?")
args = append(args, teamID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]adjustmentDTO, 0)
for rows.Next() {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return adjustmentDTO{}, err
}
if !boolDefault(values["manual_adjustment.enabled"], false) {
return adjustmentDTO{}, stateInvalid("人工调整当前未启用")
}
if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" {
return adjustmentDTO{}, badRequest("reason 必填", nil)
}
switch in.Category {
case "penalty", "bonus", "correction":
default:
return adjustmentDTO{}, badRequest("category 非法", in.Category)
}
if in.TeamID == 0 {
return adjustmentDTO{}, badRequest("teamId 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID))
if isForeignKeyViolation(err) {
return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil)
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by)
VALUES (?, ?, 'adjustment', ?, ?, ?)`,
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.create", "adjustment", id, nil, in)
return s.getAdjustment(ctx, id)
}
func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments WHERE id = ?`, id).
Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
return a, nil
}
func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var voided bool
if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
return adjustmentDTO{}, internal(err)
}
if voided {
return adjustmentDTO{}, stateInvalid("该人工调整已作废")
}
if _, err := tx.ExecContext(ctx,
`UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`,
nullID(userID), reason, id); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason})
return s.getAdjustment(ctx, id)
}
// ---------- audit logs ----------
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if entityType != "" {
where = append(where, "entity_type = ?")
args = append(args, entityType)
}
if userID > 0 {
where = append(where, "user_id = ?")
args = append(args, userID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at
FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]auditLogDTO, 0)
for rows.Next() {
var (
a auditLogDTO
uid, eid sql.NullInt64
before sql.NullString
after sql.NullString
ip, reqID sql.NullString
)
if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
a.UserID = i64Ptr(uid)
a.EntityID = i64Ptr(eid)
a.BeforeJSON = strPtr(before)
a.AfterJSON = strPtr(after)
a.IP = strPtr(ip)
a.RequestID = strPtr(reqID)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// ---------- backup ----------
// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
if strings.TrimSpace(s.dataDir) == "" {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
dir := filepath.Join(s.dataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return backupResultDTO{}, internal(err)
}
now := time.Now()
stamp := now.Format("20060102-150405")
path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp))
if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
return backupResultDTO{}, internal(err)
}
eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp))
if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil {
return backupResultDTO{}, internal(err)
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path})
return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil
}
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成员姓名 JSON 数组 ----------
func parseNames(ns sql.NullString) []string {
if !ns.Valid || ns.String == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(ns.String), &out); err != nil {
return nil
}
return out
}
func marshalNames(names []string) any {
if len(names) == 0 {
return nil
}
raw, err := json.Marshal(names)
if err != nil {
return nil
}
return string(raw)
}
// ---------- results ----------
const resultColumns = `id, event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, verified_by, verified_at, note`
func scanResult(row interface{ Scan(...any) error }) (resultDTO, error) {
var (
r resultDTO
rank sql.NullInt64
text, unit sql.NullString
value, override sql.NullFloat64
recordedBy, verifiedBy sql.NullInt64
recordedAt, verifiedAt, note sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie,
&override, &r.Status, &r.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note); err != nil {
return r, err
}
r.Rank = intPtr(rank)
r.ResultText = text.String
r.ResultValue = f64Ptr(value)
r.Unit = unit.String
r.PointsOverride = f64Ptr(override)
r.RecordedBy = i64Ptr(recordedBy)
r.RecordedAt = strPtr(recordedAt)
r.VerifiedBy = i64Ptr(verifiedBy)
r.VerifiedAt = strPtr(verifiedAt)
r.Note = strPtr(note)
return r, nil
}
func (s *server) resultsByEvent(ctx context.Context, eventID int64) ([]resultDTO, error) {
return queryResults(ctx, s.ev, `SELECT `+resultColumns+` FROM results WHERE event_id = ? ORDER BY id`, eventID)
}
func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]resultDTO, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]resultDTO, 0)
for rows.Next() {
r, err := scanResult(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
r, err := scanResult(s.ev.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, internal(err)
}
return r, nil
}
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if eventID > 0 {
where = append(where, "event_id = ?")
args = append(args, eventID)
}
if state != "" {
where = append(where, "state = ?")
args = append(args, state)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM results`+clause, args...).Scan(&total); err != nil {
return pageResult[resultDTO]{}, internal(err)
}
query := `SELECT ` + resultColumns + ` FROM results` + clause + ` ORDER BY id LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
items, err := queryResults(ctx, s.ev, query, args...)
if err != nil {
return pageResult[resultDTO]{}, err
}
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
for i, it := range items {
if it.EntryID == 0 {
return nil, badRequest("results[].entryId 必填", i)
}
var entryEvent int64
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("entryId 不存在", it.EntryID)
}
return nil, internal(err)
}
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, datetime('now'), ?, datetime('now'))
ON CONFLICT(entry_id) DO UPDATE SET
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = excluded.unit,
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
note = excluded.note,
recorded_by = excluded.recorded_by,
recorded_at = excluded.recorded_at,
updated_at = datetime('now')`,
eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"),
nullID(userID), nullStr(it.Note)); err != nil {
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.resultsByEvent(ctx, eventID)
}
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " AND "
}
out += p
}
return out
}
// ---------- ledger ----------
func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
}
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.LedgerEntry, 0)
for rows.Next() {
var (
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
return nil, internal(err)
}
l.AthleteID = athlete.Int64
l.EventID = event.Int64
l.EntryID = entry.Int64
l.ResultID = result.Int64
l.CreatedAt = created.String
out = append(out, l)
}
return out, rows.Err()
}
+193
View File
@@ -0,0 +1,193 @@
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// apiError 是可直接映射为统一响应结构的业务错误(见 devdocs/05 第 8 节错误码)。
type apiError struct {
status int
code string
message string
detail any
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
func badRequest(message string, detail any) *apiError {
return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail}
}
func notFound(message string) *apiError {
return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message}
}
func conflict(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message}
}
func stateInvalid(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message}
}
func internal(err error) *apiError {
return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()}
}
func notImplemented(message string) *apiError {
return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message}
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
type handler func(w http.ResponseWriter, r *http.Request) (any, error)
// handle 统一把业务结果/错误写成 REST 响应。
func (s *server) handle(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := fn(w, r)
if err != nil {
if errors.Is(err, errHandled) {
return
}
writeAPIError(w, s.logger, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}
}
// handleVoid 用于无返回体的写操作。
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeAPIError(w, s.logger, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败", "code", api.code, "err", api.message)
}
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": api.code,
"message": api.message,
},
}
if api.detail != nil {
body["error"].(map[string]any)["detail"] = api.detail
}
writeJSON(w, api.status, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
}
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
func pageQuery(r *http.Request) (page, size int) {
page = intQuery(r, "page", 1)
size = intQuery(r, "size", 50)
if page < 1 {
page = 1
}
switch {
case size < 1:
size = 50
case size > 500:
size = 500
}
return page, size
}
func intQuery(r *http.Request, key string, def int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
return n
}
func int64Query(r *http.Request, key string) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// pathID 解析路由通配符 {id}。
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return 0, badRequest("路径参数 "+name+" 非法", raw)
}
return n, nil
}
// pageResult 是分页响应体(见 devdocs/05 第 6 节)。
type pageResult[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// mapDomainError 把 domain 引擎的哨兵错误映射为 REST 错误码。
func mapDomainError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrManualAdjustmentDisabled),
errors.Is(err, domain.ErrAdjustmentReasonRequired),
errors.Is(err, domain.ErrCompareRequired),
errors.Is(err, domain.ErrUnknownBasis),
errors.Is(err, domain.ErrUnknownLedgerOp):
return stateInvalid(err.Error())
case errors.Is(err, domain.ErrProfileNotFound):
return badRequest("计分通道引用的积分模板不存在", err.Error())
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
return stateInvalid("该积分流水已作废,不能重复作废")
case errors.Is(err, domain.ErrLedgerNotFound):
return notFound("积分流水不存在")
default:
return internal(err)
}
}
-107
View File
@@ -1,107 +0,0 @@
// Package httpapi 提供 HTTP 接口与静态资源托管。
package httpapi
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": opts.Version,
})
})
assets, err := webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "前端资源不可用", http.StatusServiceUnavailable)
})
} else {
mux.Handle("/", staticHandler(assets))
}
return withRequestLog(mux, logger)
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Debug("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"dur", time.Since(start),
)
})
}
+109
View File
@@ -0,0 +1,109 @@
package httpapi
import (
"context"
"io"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// rulesRoutes 挂载规则包的导出与导入(导入支持 ?preview=1 只预览差异)。
func (s *server) rulesRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
}
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
pack, err := rules.Export(s.ev, rules.Meta{})
if err != nil {
return nil, internal(err)
}
body, err := rules.Marshal(pack)
if err != nil {
return nil, internal(err)
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="rules.yaml"`)
if _, err := w.Write(body); err != nil {
s.logger.Error("规则包写出失败", "err", err)
}
return nil, errHandled
}
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
return nil, badRequest("读取请求体失败", nil)
}
if len(body) == 0 {
return nil, badRequest("请求体为空", nil)
}
pack, err := rules.Parse(body)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
cur, err := rules.Load(s.ev)
if err != nil {
return nil, internal(err)
}
cs, err := rules.Diff(cur, pack)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
return cs, nil
}
res, err := rules.Apply(s.ev, cs)
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分必须全量重算。
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
return nil, internal(err)
}
return res, nil
}
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
for _, eventID := range ids {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+642
View File
@@ -0,0 +1,642 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。
type queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// ---------- 加载:DB 行 → domain 输入 ----------
func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, error) {
var (
e domain.Event
scheduledAt sql.NullString
venue, remark sql.NullString
)
err := q.QueryRowContext(ctx,
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark
FROM events WHERE id = ?`, eventID).
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark)
if errors.Is(err, sql.ErrNoRows) {
return domain.Event{}, notFound("小项不存在")
}
if err != nil {
return domain.Event{}, internal(err)
}
e.ScheduledAt = scheduledAt.String
e.Venue = venue.String
e.Remark = remark.String
return e, nil
}
func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.EventCategory, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.EventCategory, 0)
for rows.Next() {
var (
c domain.EventCategory
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = gender.String
if ageMin.Valid {
v := int(ageMin.Int64)
c.AgeMin = &v
}
if ageMax.Valid {
v := int(ageMax.Int64)
c.AgeMax = &v
}
out = append(out, c)
}
return out, rows.Err()
}
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.ScoreChannel, 0)
for rows.Next() {
var c domain.ScoreChannel
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
return nil, internal(err)
}
out = append(out, c)
}
return out, rows.Err()
}
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
out := make(map[int64]domain.ScoringProfile, len(ids))
for id := range ids {
var p domain.ScoringProfile
err := q.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("积分模板不存在", id)
}
if err != nil {
return nil, internal(err)
}
rows, err := q.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id)
if err != nil {
return nil, internal(err)
}
rules := make([]domain.ScoringRule, 0)
for rows.Next() {
var r domain.ScoringRule
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
_ = rows.Close()
return nil, internal(err)
}
rules = append(rules, r)
}
err = rows.Err()
_ = rows.Close()
if err != nil {
return nil, internal(err)
}
p.Rules = rules
out[id] = p
}
return out, nil
}
func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) {
query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.status, COALESCE(a.name, '')
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE e.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY e.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Entry, 0)
for rows.Next() {
var (
e domain.Entry
athleteID sql.NullInt64
memberNames sql.NullString
)
if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil {
return nil, internal(err)
}
if athleteID.Valid {
e.AthleteID = athleteID.Int64
}
e.MemberNames = parseNames(memberNames)
out = append(out, e)
}
return out, rows.Err()
}
func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) {
query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit,
r.is_tie, r.points_override, r.status, r.state, r.note
FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY r.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Result, 0)
for rows.Next() {
var (
r domain.Result
rank sql.NullInt64
text, unit, note sql.NullString
value, override sql.NullFloat64
)
if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit,
&r.IsTie, &override, &r.Status, &r.State, &note); err != nil {
return nil, internal(err)
}
if rank.Valid {
r.Rank = int(rank.Int64)
}
r.ResultText = text.String
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.Unit = unit.String
if override.Valid {
v := override.Float64
r.PointsOverride = &v
}
r.Note = note.String
out = append(out, r)
}
return out, rows.Err()
}
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax}
values, err := loadSettingsMap(ctx, q)
if err != nil {
return cfg, err
}
cfg.Enabled = values["record_bonus.enabled"] == "true"
if values["record_bonus.mode"] == string(domain.RecordBonusSum) {
cfg.Mode = domain.RecordBonusSum
}
return cfg, nil
}
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state
FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RecordBreak, 0)
for rows.Next() {
var (
b domain.RecordBreak
value sql.NullFloat64
text sql.NullString
)
if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil {
return nil, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
out = append(out, b)
}
return out, rows.Err()
}
// ---------- 计分 ----------
// computeEventScore 计算某小项(或某组别)的流水与名次明细。
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
ev, err := loadEventQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
channels, err := loadChannelsQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
profileIDs := make(map[int64]bool)
for _, ch := range channels {
profileIDs[ch.ProfileID] = true
}
profiles, err := loadProfilesQ(ctx, q, profileIDs)
if err != nil {
return domain.EventScore{}, err
}
entries, err := loadEntriesQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
results, err := loadResultsQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
bonusCfg, err := loadRecordBonusQ(ctx, q)
if err != nil {
return domain.EventScore{}, err
}
breaks, err := loadRecordBreaksQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
var total domain.EventScore
for _, cat := range cats {
if categoryID > 0 && cat.ID != categoryID {
continue
}
score, err := domain.ScoreEvent(domain.EventScoreInput{
Event: ev,
Category: cat,
Channels: channels,
Profiles: profiles,
Entries: entries,
Results: results,
RecordBonus: &bonusCfg,
RecordBreaks: breaks,
})
if err != nil {
return domain.EventScore{}, mapDomainError(err)
}
total.Ledger = append(total.Ledger, score.Ledger...)
total.Ranked = append(total.Ranked, score.Ranked...)
}
return total, nil
}
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
scope := `source_type = 'result' AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, categoryID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND `+scope,
append([]any{nullID(actorID), reason}, args...)...); err != nil {
return domain.EventScore{}, internal(err)
}
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
for _, l := range score.Ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
return domain.EventScore{}, internal(err)
}
}
return score, nil
}
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
cur, ok := best[rr.ResultID]
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
best[rr.ResultID] = rr
}
}
for id, rr := range best {
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = ? WHERE id = ?`, rr.Rank, id); err != nil {
return internal(err)
}
}
return nil
}
// ---------- 发布 / 撤销 / 作废 ----------
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
updated_at = datetime('now')
WHERE event_id = ?`
args := []any{nullID(userID), in.EventID}
if catID > 0 {
setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil {
return nil, internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.publish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
WHERE event_id = ? AND state = 'published'`
args := []any{in.EventID}
if catID > 0 {
query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.unpublish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
before resultDTO
eventID int64
catID int64
)
row := tx.QueryRowContext(ctx,
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
var (
rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64
)
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
return resultDTO{}, internal(err)
}
before.EventID = eventID
before.Rank = intPtr(rank)
before.ResultText = text.String
before.ResultValue = f64Ptr(value)
before.Unit = unit.String
before.PointsOverride = f64Ptr(override)
before.RecordedBy = i64Ptr(recordedBy)
before.RecordedAt = strPtr(recordedAt)
before.VerifiedBy = i64Ptr(verifiedBy)
before.VerifiedAt = strPtr(verifiedAt)
before.Note = strPtr(note)
if _, err := tx.ExecContext(ctx,
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
return resultDTO{}, err
}
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
if err != nil {
return resultDTO{}, internal(err)
}
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
return resultDTO{}, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil {
return resultDTO{}, err
}
if err := tx.Commit(); err != nil {
return resultDTO{}, internal(err)
}
s.audit(ctx, "result.void", "result", resultID, before, after)
return s.getResult(ctx, resultID)
}
// ---------- 变更日志与版本 ----------
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
snapshot, err := json.Marshal(results)
if err != nil {
return internal(err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, '[]', ?, ?)`,
eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
}
return nil
}
func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error {
for _, item := range items {
payload, err := json.Marshal(item)
if err != nil {
return internal(err)
}
if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil {
return err
}
}
return nil
}
// bumpVersion 写一条 change_log;id 即实时协议的 version。
func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error {
raw := []byte("{}")
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return internal(err)
}
raw = b
}
var eid any
if entityID != 0 {
eid = entityID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id)
VALUES (?, ?, ?, ?, ?)`,
entityType, eid, action, string(raw), nullID(actorID)); err != nil {
return internal(err)
}
return nil
}
// audit 登记审计(由 middleware.Auditor 在请求成功后落库)。
func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) {
middleware.Record(ctx, middleware.AuditEntry{
Action: action,
EntityType: entityType,
EntityID: entityID,
Before: before,
After: after,
})
}
+251
View File
@@ -0,0 +1,251 @@
// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。
package httpapi
import (
"database/sql"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。
type DisplaySender interface {
SendDisplayCommand(action string, payload any) error
}
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
// System 是系统库连接(users/sessions)。
System *sql.DB
// Event 是赛事库连接(teams/events/results/ledger/audit…)。
Event *sql.DB
// Auth 可选;为空时用 System 自动构建。
Auth *auth.Service
// DataDir 用于备份目录 data/backups。
DataDir string
// Display 可选,实时层接入后传入。
Display DisplaySender
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
WS http.Handler
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
type server struct {
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
display DisplaySender
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
svc := opts.Auth
if svc == nil {
svc = auth.New(opts.System, auth.Options{Logger: logger})
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
display: opts.Display,
idem: newIdempotencyStore(10 * time.Minute),
}
mux := http.NewServeMux()
s.routes(mux)
s.exportRoutes(mux)
s.rulesRoutes(mux)
if opts.WS != nil {
mux.Handle("/ws", opts.WS)
}
assets := opts.WebFS
if assets == nil {
var err error
assets, err = webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
}
}
if assets != nil {
mux.Handle("/", staticHandler(assets))
}
var h http.Handler = mux
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.Authenticate(svc)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
return h
}
func middlewareUserID(r *http.Request) int64 {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return 0
}
return u.ID
}
// public 注册公开接口(guest 可读)。
func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version})
})
// 认证
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
// 系统配置(guest 一律 403)
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
mux.HandleFunc("GET /api/sports/{id}", s.public(s.getSportHandler))
mux.HandleFunc("PUT /api/sports/{id}", s.write(auth.RoleRecorder, s.updateSportHandler))
mux.HandleFunc("DELETE /api/sports/{id}", s.writeVoid(auth.RoleRecorder, s.deleteSportHandler))
mux.HandleFunc("GET /api/events", s.public(s.listEventsHandler))
mux.HandleFunc("POST /api/events", s.write(auth.RoleRecorder, s.createEventHandler))
mux.HandleFunc("GET /api/events/{id}", s.public(s.getEventHandler))
mux.HandleFunc("PUT /api/events/{id}", s.write(auth.RoleRecorder, s.updateEventHandler))
mux.HandleFunc("DELETE /api/events/{id}", s.writeVoid(auth.RoleRecorder, s.deleteEventHandler))
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
mux.HandleFunc("GET /api/scoring-profiles/{id}", s.public(s.getProfileHandler))
mux.HandleFunc("PUT /api/scoring-profiles/{id}", s.write(auth.RoleRecorder, s.updateProfileHandler))
mux.HandleFunc("GET /api/athletes", s.public(s.listAthletesHandler))
mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler))
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 积分与排名
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
mux.HandleFunc("GET /api/standings/athletes", s.public(s.athleteStandingsHandler))
mux.HandleFunc("GET /api/standings/teams/{id}/breakdown", s.public(s.breakdownHandler))
mux.HandleFunc("GET /api/adjustments", s.public(s.listAdjustmentsHandler))
mux.HandleFunc("POST /api/adjustments", s.write(auth.RoleRecorder, s.createAdjustmentHandler))
mux.HandleFunc("POST /api/adjustments/{id}/void", s.write(auth.RoleRecorder, s.voidAdjustmentHandler))
// 纪录
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入"))
}
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}
+195
View File
@@ -0,0 +1,195 @@
package httpapi_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。
type testEnv struct {
t *testing.T
server *httptest.Server
sys *sql.DB
ev *sql.DB
auth *auth.Service
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db")
if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
ev.SetMaxOpenConns(1)
t.Cleanup(func() { _ = ev.Close() })
if err := store.MigrateEvent(ev); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
handler := httpapi.New(httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
}
func (e *testEnv) createUser(username, password, role string) int64 {
e.t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
e.t.Fatalf("生成密码哈希: %v", err)
}
res, err := e.sys.Exec(
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role)
if err != nil {
e.t.Fatalf("创建用户 %s: %v", username, err)
}
id, _ := res.LastInsertId()
return id
}
func (e *testEnv) client() (*http.Client, *cookiejar.Jar) {
e.t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
e.t.Fatalf("创建 cookie jar: %v", err)
}
return &http.Client{Jar: jar}, jar
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiErrorBody `json:"error"`
}
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
}
// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。
func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) {
return e.doH(client, method, path, body, csrf, nil)
}
// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。
func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) {
e.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
e.t.Fatalf("序列化请求体: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, e.server.URL+path, reader)
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if csrf {
token := e.csrfToken(client)
if token == "" {
e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path)
}
req.Header.Set("X-CSRF-Token", token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw)
}
}
return resp.StatusCode, env
}
func (e *testEnv) csrfToken(client *http.Client) string {
u, err := url.Parse(e.server.URL)
if err != nil {
e.t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == auth.CookieCSRF {
return c.Value
}
}
return ""
}
func (e *testEnv) login(client *http.Client, username, password string) envelope {
e.t.Helper()
status, env := e.do(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": username, "password": password}, false)
if status != http.StatusOK || !env.OK {
e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error)
}
return env
}
func decodeData[T any](t *testing.T, env envelope) T {
t.Helper()
var out T
if err := json.Unmarshal(env.Data, &out); err != nil {
t.Fatalf("解析 data: %v (%s)", err, env.Data)
}
return out
}