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:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e)
|
||||
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, "报名项不存在")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e); 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e); 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, ¬e, &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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user