fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+46 -1
View File
@@ -2,11 +2,14 @@ package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -16,6 +19,9 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
@@ -115,7 +121,10 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
defer cleanup()
opts.RulesPath = rulesPath
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
dst, err := projectExportPath(dir, time.Now())
if err != nil {
return nil, internal(err)
}
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
return nil, internal(err)
}
@@ -127,6 +136,16 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, errHandled
}
// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
// 后写的会截断先写的文件,导致先返回的响应内容损坏。
func projectExportPath(dir string, now time.Time) (string, error) {
suffix := make([]byte, 4)
if _, err := rand.Read(suffix); err != nil {
return "", err
}
return filepath.Join(dir, "project-"+now.Format("20060102-150405")+"-"+hex.EncodeToString(suffix)+".zip"), nil
}
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
@@ -155,6 +174,7 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
}
@@ -183,6 +203,22 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, internal(err)
}
// 覆盖正在运行的赛事项目会让运行中的 event.db 被 unlink(Linux)或 rename 失败(Windows),
// 现场继续写进已删除的 inode,重启即丢数据,必须在落盘前直接拒绝。
if cur := currentProjectName(s.projectDir); cur != "" {
meta, err := export.InspectProjectZip(tmp)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
name := meta.Name
if v := strings.TrimSpace(r.FormValue("rename")); v != "" {
name = v
}
if safe, err := export.SafeProjectName(name); err == nil && strings.EqualFold(safe, cur) {
return nil, badRequest("不能覆盖正在运行的赛事项目 "+cur, nil)
}
}
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
@@ -194,6 +230,15 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return res, nil
}
// currentProjectName 从项目目录反推当前运行的赛事项目名;未配置目录时返回空串。
func currentProjectName(projectDir string) string {
cleaned := filepath.Clean(strings.TrimSpace(projectDir))
if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) {
return ""
}
return filepath.Base(cleaned)
}
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
@@ -0,0 +1,22 @@
package httpapi
import (
"testing"
"time"
)
// 同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
first, err := projectExportPath("/tmp/exports", now)
if err != nil {
t.Fatalf("生成导出路径: %v", err)
}
second, err := projectExportPath("/tmp/exports", now)
if err != nil {
t.Fatalf("生成导出路径: %v", err)
}
if first == second {
t.Fatalf("同一秒内两次导出不应撞名,均为 %q", first)
}
}
@@ -0,0 +1,148 @@
package httpapi_test
import (
"archive/zip"
"bytes"
"io"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// exportProjectZip 通过导出接口取一份合法赛事包,供导入测试使用。
func (e *testEnv) exportProjectZip(t *testing.T, client *http.Client) []byte {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
return body
}
// postProjectImport 以 multipart 上传赛事包,附带 overwrite / rename 表单字段。
func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []byte, fields map[string]string) (int, []byte) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatalf("写表单字段: %v", err)
}
}
fw, err := w.CreateFormFile("file", "project.zip")
if err != nil {
t.Fatalf("创建上传字段: %v", err)
}
if _, err := fw.Write(pack); err != nil {
t.Fatalf("写赛事包: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/project/import", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。
func TestImportProjectZipRejectsRunningProject(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "未命名赛事")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
pack := env.exportProjectZip(t, client)
status, raw := env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1"})
if status != http.StatusBadRequest {
t.Fatalf("覆盖运行中项目应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "正在运行") {
t.Fatalf("错误信息应说明不能覆盖运行中项目,得到 %s", raw)
}
// 用 rename 指定成当前项目名同样要拒绝。
status, raw = env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1", "rename": "未命名赛事"})
if status != http.StatusBadRequest {
t.Fatalf("rename 成运行中项目应 400,得到 %d %s", status, raw)
}
// 换成别的项目名仍应允许导入。
status, raw = env.postProjectImport(t, client, pack, map[string]string{"rename": "备份赛事"})
if status != http.StatusOK {
t.Fatalf("换名导入应 200,得到 %d %s", status, raw)
}
}
// 并发导出不能撞同名文件,否则先返回的响应会被后写的截断(P2-6)。
func TestExportProjectZipConcurrentUnique(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
const n = 6
var wg sync.WaitGroup
errs := make(chan string, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
errs <- "请求失败: " + err.Error()
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
errs <- "导出失败 status=" + resp.Status
return
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
errs <- "响应不是完整 zip: " + err.Error()
return
}
found := false
for _, f := range zr.File {
if f.Name == "meta.yaml" {
found = true
}
}
if !found {
errs <- "zip 缺少 meta.yaml"
}
}()
}
wg.Wait()
close(errs)
for msg := range errs {
t.Errorf("并发导出: %s", msg)
}
}
+12 -1
View File
@@ -43,7 +43,11 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
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})
// 登录时请求 ctx 里还是 guest,Auditor 取不到用户,需在审计条目里显式带上 actorID。
middleware.Record(r.Context(), middleware.AuditEntry{
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
After: map[string]any{"username": u.Username},
})
return userDTO(u), nil
}
@@ -82,6 +86,9 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if len(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
if len(in.NewPassword) > auth.MaxPasswordBytes {
return nil, badRequest(fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
if in.NewPassword == in.CurrentPassword {
return nil, badRequest("新密码不能与原密码相同", nil)
}
@@ -525,8 +532,12 @@ type entryImportResult struct {
Errors []string `json:"errors"`
}
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
const maxEntryImportBytes = 32 << 20
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
@@ -0,0 +1,87 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
func TestLoginAuditRecordsUserID(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var auditUser sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
t.Fatalf("查询登录审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
}
// 按 userId 过滤必须能查到这条登录记录。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
_, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
page := decodeData[map[string]any](t, env2)
items, _ := page["items"].([]any)
found := false
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if item["action"] == "auth.login" && item["userId"] == float64(userID) {
found = true
}
}
if !found {
t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
}
}
// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
func TestLoginRejectsCrossOrigin(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
body := map[string]any{"username": "recorder", "password": "recorder-pass"}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": "http://evil.example"})
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
// 同源(Origin 与 Host 一致)仍可登录。
status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": env.server.URL})
if status != http.StatusOK || !env2.OK {
t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
}
}
// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
func TestChangePasswordRejectsTooLong(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": tooLong,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
}
}
+13 -3
View File
@@ -85,6 +85,11 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
if err != nil {
return nil, err
}
// 展示字段以全量单位表为准(含停用单位),否则停用单位的运动员会显示空白单位名。
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
genders := make(map[int64]*string, len(in.Athletes))
for _, a := range in.Athletes {
if a.Gender != "" {
@@ -95,7 +100,7 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
rows := domain.BuildAthleteStandings(in)
out := make([]athleteStandingDTO, 0, len(rows))
for _, row := range rows {
out = append(out, athleteStandingDTO{
item := athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
@@ -103,12 +108,17 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
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,
})
}
if t, ok := teams[row.TeamID]; ok {
item.TeamCode = t.Code
item.TeamName = t.Name
item.ShortName = t.ShortName
}
out = append(out, item)
}
return out, nil
}
@@ -0,0 +1,48 @@
package httpapi_test
import (
"net/http"
"testing"
)
// 个人榜的单位展示字段必须取全量单位表:停用但有积分的单位也要显示名称与简称。
func TestAthleteStandingsUsesFullTeamTable(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,
"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)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
env.exec(t, `UPDATE teams SET active = 0 WHERE id = ?`, ids.teamB)
_, env2 := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
rows := decodeData[[]map[string]any](t, env2)
seen := map[int64]map[string]any{}
for _, row := range rows {
seen[int64(row["athleteId"].(float64))] = row
}
active := seen[ids.athleteA]
if active == nil || active["teamName"] != "第一分公司" || active["shortName"] != "一司" {
t.Fatalf("在职单位的个人行应带单位名与简称: %+v", active)
}
stopped := seen[ids.athB]
if stopped == nil {
t.Fatalf("停用单位的运动员仍应在个人榜上: %+v", rows)
}
if stopped["teamName"] != "第二分公司" || stopped["shortName"] != "二司" {
t.Fatalf("停用单位的个人行应补齐单位名与简称,得到 %+v", stopped)
}
}
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
@@ -140,6 +141,9 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt
if len(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
if len(in.Password) > auth.MaxPasswordBytes {
return userListItemDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
displayName := strings.TrimSpace(in.DisplayName)
if displayName == "" {
displayName = username
@@ -226,6 +230,8 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa
generated = true
} else if len(password) < 8 {
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
} else if len(password) > auth.MaxPasswordBytes {
return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
// SetPassword 会作废该用户全部会话,被重置的人必须重新登录。
if err := s.auth.SetPassword(ctx, u.Username, password, true); err != nil {
@@ -0,0 +1,44 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// 建号与重置密码都必须拒绝超过 bcrypt 上限的密码,避免静默截断(P2-6)。
func TestUserPasswordLengthLimits(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u1", "password": tooLong, "role": "recorder",
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长密码建号应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u1", "password": "u1-pass-123", "role": "recorder",
}, true)
if status != http.StatusOK {
t.Fatalf("建号应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
id, _ := created["id"].(float64)
if id == 0 {
t.Fatalf("建号响应缺少 id: %+v", created)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", int64(id)),
map[string]any{"password": tooLong}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error)
}
}
+44 -4
View File
@@ -19,9 +19,10 @@ var errHandled = errors.New("httpapi: response already written")
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
// 进程重启后客户端重试会重新执行,属于可接受的降级。
type idempotencyStore struct {
mu sync.Mutex
entries map[string]idempotentResponse
ttl time.Duration
mu sync.Mutex
entries map[string]idempotentResponse
inflight map[string]chan struct{}
ttl time.Duration
}
type idempotentResponse struct {
@@ -33,7 +34,34 @@ func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
return &idempotencyStore{
entries: make(map[string]idempotentResponse),
inflight: make(map[string]chan struct{}),
ttl: ttl,
}
}
// begin 声明某个 key 的执行权:leader=true 表示由本请求执行,
// 否则返回等待通道——等首次执行结束后回放其结果,避免同一 requestId 并发双执行。
func (s *idempotencyStore) begin(key string) (leader bool, wait <-chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.inflight[key]; ok {
return false, ch
}
ch := make(chan struct{})
s.inflight[key] = ch
return true, ch
}
// end 释放执行权并唤醒等待者。
func (s *idempotencyStore) end(key string) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.inflight[key]; ok {
delete(s.inflight, key)
close(ch)
}
}
func (s *idempotencyStore) get(key string) ([]byte, bool) {
@@ -106,6 +134,18 @@ func (s *server) cached(inner http.Handler) http.HandlerFunc {
writeCached(w, body)
return
}
// 占位锁只放在最外层:内层 idempotent 与这里共用同一把 key,
// 若两处都抢占会自我等待。并发同一 requestId 由这里串行化并回放首次结果。
leader, wait := s.idem.begin(key)
for !leader {
<-wait
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return
}
leader, wait = s.idem.begin(key)
}
defer s.idem.end(key)
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(cw, r)
@@ -0,0 +1,47 @@
package httpapi
import (
"testing"
"time"
)
// 同一 requestId 并发时只能有一个执行者,其余请求等首次结束后回放其结果。
func TestIdempotencyStoreSerializesSameKey(t *testing.T) {
store := newIdempotencyStore(time.Minute)
leader, _ := store.begin("k")
if !leader {
t.Fatal("首个请求应拿到执行权")
}
done := make(chan struct{})
go func() {
leader, wait := store.begin("k")
if leader {
t.Error("并发同 key 不应拿到执行权")
}
<-wait
close(done)
}()
// 等 goroutine 进入等待后再检查它不会提前返回。
time.Sleep(20 * time.Millisecond)
select {
case <-done:
t.Fatal("未释放执行权前等待者不应返回")
default:
}
store.end("k")
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("释放执行权后应唤醒等待者")
}
// 释放后应能重新拿到执行权。
if leader, _ := store.begin("k"); !leader {
t.Fatal("执行权释放后应可再次获取")
}
store.end("k")
}
+59 -7
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -176,7 +178,12 @@ func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
}
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))
return teamByIDQ(ctx, s.ev, id)
}
// teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。
func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return teamDTO{}, notFound("单位不存在")
}
@@ -190,7 +197,13 @@ 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,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.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)
@@ -204,11 +217,28 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if err != nil {
return teamDTO{}, internal(err)
}
return s.getTeam(ctx, id)
team, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamDTO{}, err
}
// 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
return teamDTO{}, err
}
if err := tx.Commit(); err != nil {
return teamDTO{}, internal(err)
}
return team, nil
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
res, err := s.ev.ExecContext(ctx,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.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)
@@ -221,16 +251,38 @@ func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO,
if err := requireChanges(res, "单位不存在"); err != nil {
return teamDTO{}, err
}
return s.getTeam(ctx, id)
team, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamDTO{}, err
}
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
return teamDTO{}, err
}
if err := tx.Commit(); err != nil {
return teamDTO{}, internal(err)
}
return team, nil
}
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)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
return requireChanges(res, "单位不存在")
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
if err := requireChanges(res, "单位不存在"); err != nil {
return err
}
if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
return tx.Commit()
}
// ---------- athletes ----------
+61 -7
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
@@ -53,12 +55,9 @@ func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, 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("小项不存在")
}
e, err := eventByIDQ(ctx, s.ev, id)
if err != nil {
return eventDTO{}, internal(err)
return eventDTO{}, err
}
cats, err := s.listCategories(ctx, id)
if err != nil {
@@ -73,11 +72,29 @@ func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
return e, nil
}
// eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。
func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
e, err := scanEvent(q.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)
}
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,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -99,11 +116,24 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
if err != nil {
return eventDTO{}, internal(err)
}
// 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); 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,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.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 = ?
@@ -125,9 +155,24 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
// bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。
func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error {
ev, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return err
}
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(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 {
@@ -150,6 +195,9 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
@@ -228,6 +276,9 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
@@ -291,6 +342,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
+103 -3
View File
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- settings ----------
@@ -34,7 +35,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
}
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, s.ev)
return s.settingsSnapshotQ(ctx, s.ev)
}
// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, q)
if err != nil {
return dto.EventSettings{}, err
}
@@ -43,7 +50,7 @@ func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
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)
err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
@@ -114,6 +121,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err
}
if p.RotateSeconds != nil {
// 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
}
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
}
@@ -121,6 +132,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
// 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
if *p.IndividualToTeamWeight <= 0 {
return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
}
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
@@ -160,12 +175,26 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
}
// 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
snapshot, err := s.settingsSnapshotQ(ctx, tx)
if err != nil {
return dto.EventSettings{}, err
}
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
return dto.EventSettings{}, err
}
if err := tx.Commit(); err != nil {
return dto.EventSettings{}, internal(err)
}
return s.getSettings(ctx)
return snapshot, nil
}
// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
const (
minRotateSeconds = 5
maxRotateSeconds = 120
)
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
@@ -216,11 +245,28 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitSettingList(values["standings.tie_break"]),
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
func splitSettingList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
@@ -363,6 +409,15 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if err != nil {
return teamBreakdownDTO{}, err
}
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return teamBreakdownDTO{}, err
}
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
if weight <= 0 {
weight = 1
}
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, '')
@@ -401,6 +456,9 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
if item.AthleteID != nil && *item.AthleteID != 0 {
item.Points *= weight
}
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
@@ -455,6 +513,9 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
if err := s.validateRecordBreakEntry(ctx, in); err != nil {
return recordBreakDTO{}, err
}
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')`,
@@ -472,6 +533,45 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
return s.getRecordBreak(ctx, id)
}
// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
var (
entryEvent int64
recordEligible bool
)
err := s.ev.QueryRowContext(ctx,
`SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
in.EntryID).Scan(&entryEvent, &recordEligible)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("entryId 不存在", in.EntryID)
}
if err != nil {
return internal(err)
}
if entryEvent != in.EventID {
return badRequest("entryId 不属于该小项", in.EntryID)
}
if !recordEligible {
return badRequest("该小项未开启破纪录登记", in.EventID)
}
var (
state string
status string
)
err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
}
if err != nil {
return internal(err)
}
if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
}
return nil
}
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var (
b recordBreakDTO
+208
View File
@@ -0,0 +1,208 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// changeLogCount 统计某实体某动作的变更记录数。
func changeLogCount(t *testing.T, env *testEnv, entity, action string) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ? AND action = ?`,
entity, action).Scan(&n); err != nil {
t.Fatalf("统计 change_log: %v", err)
}
return n
}
// 配置写操作必须写 change_log,否则 broadcastChanges 推空、大屏不刷新;
// 同时校验数值范围,浏览器 min/max 只是提示,不能当服务端校验。
func TestSettingsPatchValidationAndChangeLog(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
for _, tc := range []struct {
name string
body map[string]any
}{
{"轮播间隔过小", map[string]any{"display": map[string]any{"rotateSeconds": 1}}},
{"轮播间隔过大", map[string]any{"display": map[string]any{"rotateSeconds": 121}}},
{"折算系数为 0", map[string]any{"standings": map[string]any{"individualToTeamWeight": 0}}},
{"折算系数为负", map[string]any{"standings": map[string]any{"individualToTeamWeight": -1}}},
} {
status, env2 := env.do(client, http.MethodPut, "/api/settings", tc.body, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, env2.Error)
}
}
before := changeLogCount(t, env, "settings", "upsert")
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"name": "秋季运动会", "subtitle": ""},
"display": map[string]any{"rotateSeconds": 30},
"standings": map[string]any{"individualToTeamWeight": 0.5},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("合法配置应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
t.Fatalf("配置写操作应新增 1 条 settings change_log,得到 %d → %d", before, got)
}
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
settings := decodeData[map[string]any](t, env2)
event := settings["event"].(map[string]any)
if event["name"] != "秋季运动会" || event["subtitle"] != nil {
t.Fatalf("空串应归一为 null、名称应落库: %+v", event)
}
display := settings["display"].(map[string]any)
if display["rotateSeconds"].(float64) != 30 {
t.Fatalf("轮播间隔应落库为 30: %+v", display)
}
}
// 破纪录登记必须基于该项目一条已发布且状态正常的成绩,否则 recorder 可凭空加分。
func TestRecordBreakRequiresPublishedResult(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")
body := map[string]any{"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50"}
// 没有成绩。
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("无成绩时登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 只有草稿成绩。
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state)
VALUES (?, ?, 1, 10.5, 'ok', 'draft')`, ids.event, ids.entryA)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("草稿成绩登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 已发布但小项不可破纪录。
env.exec(t, `UPDATE results SET state = 'published' WHERE entry_id = ?`, ids.entryA)
env.exec(t, `UPDATE events SET record_eligible = 0 WHERE id = ?`, ids.event)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("不可破纪录项目应 400,得到 %d %+v", status, env2.Error)
}
// 报名项不属于该项目。
env.exec(t, `UPDATE events SET record_eligible = 1 WHERE id = ?`, ids.event)
otherEvent := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks",
map[string]any{"eventId": otherEvent, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50"}, true)
if status != http.StatusBadRequest {
t.Fatalf("跨项目登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 合法:已发布成绩 + 可破纪录项目。
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("合法登记应 200,得到 %d %+v", status, env2.Error)
}
}
// 单位积分明细的合计必须与总积分榜同口径:个人流水乘折算系数。
func TestTeamBreakdownAppliesIndividualWeight(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,
"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)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
env.exec(t, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
breakdown := decodeData[map[string]any](t, env2)
if got := breakdown["totalPoints"].(float64); got != 5 {
t.Fatalf("合计应按系数折算为 5,得到 %v", got)
}
items := breakdown["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["points"].(float64) != 5 {
t.Fatalf("明细行也应按系数折算: %+v", items)
}
// 与总积分榜一致。
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
for _, row := range decodeData[[]map[string]any](t, env2) {
if row["teamCode"] == "A" && row["totalPoints"].(float64) != 5 {
t.Fatalf("总积分榜与明细合计应一致: %+v", row)
}
}
}
// standings.tie_break 必须真正参与排序,否则换规则包后名次仍走硬编码默认序。
func TestStandingsTieBreakFromSettings(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.exec(t, `UPDATE teams SET name = 'zzz' WHERE id = ?`, ids.teamA)
env.exec(t, `UPDATE teams SET name = 'aaa' WHERE id = ?`, ids.teamB)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.58, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
first := func() string {
t.Helper()
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
rows := decodeData[[]map[string]any](t, env2)
if len(rows) != 2 {
t.Fatalf("应返回 2 个单位,得到 %d", len(rows))
}
return rows[0]["teamCode"].(string)
}
if got := first(); got != "A" {
t.Fatalf("默认按单位编码排序应 A 在前,得到 %s", got)
}
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.tie_break', 'points,name', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
if got := first(); got != "B" {
t.Fatalf("配置 standings.tie_break=points,name 后应按名称排序(aaa 在前),得到 %s", got)
}
}
+15 -2
View File
@@ -178,7 +178,8 @@ func (s *server) listResultsPage(ctx context.Context, eventID int64, state strin
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。
// upsertDraft 批量保存草稿;已发布的成绩直接拒绝(STATE_INVALID),
// 必须先撤销发布再改,避免 rank 被就地清空而流水不动。
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 {
@@ -200,6 +201,18 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
// 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
// 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
// 尚无成绩,正常新建草稿。
case err != nil:
return nil, internal(err)
case state == string(domain.ResultStatePublished):
return nil, stateInvalid("该成绩已发布,请先撤销发布再修改")
}
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)
@@ -208,7 +221,7 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = excluded.unit,
unit = COALESCE(excluded.unit, results.unit),
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
@@ -0,0 +1,113 @@
package httpapi_test
import (
"database/sql"
"net/http"
"testing"
)
// 已发布成绩不能被「存草稿」就地覆盖:ON CONFLICT 不改 state,
// 放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
func TestSaveDraftRejectsPublishedResult(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,
"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)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "status": "ok"},
},
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "STATE_INVALID" {
t.Fatalf("已发布成绩存草稿应 409 STATE_INVALID,得到 %d %+v", status, env2.Error)
}
var (
rank int
value float64
state string
)
if err := env.ev.QueryRow(`SELECT rank, result_value, state FROM results WHERE entry_id = ?`, ids.entryA).
Scan(&rank, &value, &state); err != nil {
t.Fatalf("读取成绩: %v", err)
}
if rank != 1 || value != 11.58 || state != "published" {
t.Fatalf("被拒的存草稿不得改动已发布行: rank=%d value=%v state=%s", rank, value, state)
}
// 正确流程:先撤销发布,再存草稿。
if status, env2 := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("撤销发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("撤销发布后存草稿应 200,得到 %d %+v", status, env2.Error)
}
}
// 草稿未传 unit 时应保留原值,否则 100 米成绩会从 1:06.33 退化成 66.33。
func TestSaveDraftKeepsUnitWhenOmitted(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,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 66.33, "unit": "s", "status": "ok"},
},
}, true)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 66.33, "resultText": "1:06.33", "status": "ok"},
},
}, true)
var unit sql.NullString
if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
t.Fatalf("读取单位: %v", err)
}
if !unit.Valid || unit.String != "s" {
t.Fatalf("未传 unit 时应保留原值 s,得到 %+v", unit)
}
// 显式传 unit 仍可覆盖。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 1.1, "unit": "min", "status": "ok"},
},
}, true)
if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
t.Fatalf("读取单位: %v", err)
}
if !unit.Valid || unit.String != "min" {
t.Fatalf("显式传 unit 应覆盖为 min,得到 %+v", unit)
}
}
+87
View File
@@ -0,0 +1,87 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 单位增删改必须写 change_log,否则 broadcastChanges 推空、大屏单位名不刷新。
func TestTeamWritesWriteChangeLogAndBroadcast(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
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("建单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "upsert"); got != 1 {
t.Fatalf("建单位应写 1 条 teams change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
teamID := int64(created["id"].(float64))
found := false
for _, ch := range fake.firstBatch(t) {
if ch.Entity == "teams" && ch.Action == "upsert" && ch.ID == teamID {
found = true
}
}
if !found {
t.Fatalf("建单位后应广播 teams 变更")
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil, true); status != http.StatusOK {
t.Fatalf("删单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "void"); got != 1 {
t.Fatalf("删单位应写 1 条 teams void 变更,得到 %d", got)
}
}
// 项目、组别、通道的写操作同样要推 change_log,大屏才会刷新。
func TestEventWritesWriteChangeLog(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")
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "M400", "name": "400米跑",
"eventType": "individual", "resultType": "time", "compare": "asc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 1 {
t.Fatalf("建项目应写 1 条 events change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
eventID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", eventID), map[string]any{
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换组别应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 2 {
t.Fatalf("替换组别应再写 1 条 events change_log,得到 %d", got)
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true); status != http.StatusOK {
t.Fatalf("删项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "void"); got != 1 {
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
}
}
@@ -402,6 +402,10 @@ func TestApproveRecordBreakUpsertsRecords(t *testing.T) {
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 破纪录登记要求该报名项已有已发布成绩,先落一条。
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, result_text, unit, status, state)
VALUES (?, ?, 1, 10.5, '10.50', 's', 'ok', 'published')`, ids.event, ids.entryA)
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50",
+104 -30
View File
@@ -52,6 +52,52 @@ func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error
return nil, errHandled
}
// rulesPackCompatible 判断规则包格式版本是否与当前程序一致。
func rulesPackCompatible(pack *rules.RulePack) bool {
return pack.Format == rules.FormatName && pack.FormatVersion == rules.FormatVersion
}
// rulesImportPreview 是规则预览响应:在 ChangeSet 之上补齐前端契约里的兼容性与影响面。
type rulesImportPreview struct {
*rules.ChangeSet
// Compatible 由 formatVersion 判断;能解析到这里说明版本已兼容。
Compatible bool `json:"compatible"`
AffectedEventCount int `json:"affectedEventCount"`
}
// rulesImportResult 是规则落地响应:在 ApplyResult 之上补齐预览字段与重算条数。
// 同时带上 ChangeSet,前端落地后仍能显示这次导入的逐条差异(否则会被覆盖成「无差异」)。
type rulesImportResult struct {
*rules.ApplyResult
*rules.ChangeSet
Compatible bool `json:"compatible"`
AffectedEventCount int `json:"affectedEventCount"`
RecalculatedLedgerRows int `json:"recalculatedLedgerRows"`
}
// affectedEventCount 统计本次导入影响的小项数:计分通道变化的项目,
// 加上引用了被改动模板的项目——模板分值一变,这些项目的积分就会跟着变。
func affectedEventCount(cs *rules.ChangeSet, cur *rules.Snapshot) int {
affected := make(map[string]bool, len(cs.Channels))
for _, ch := range cs.Channels {
affected[ch.EventCode] = true
}
if len(cs.Profiles) > 0 {
changed := make(map[string]bool, len(cs.Profiles))
for _, pc := range cs.Profiles {
changed[pc.Code] = true
}
for _, ev := range cur.Events {
for _, ch := range ev.Channels {
if changed[ch.Profile] {
affected[ev.Code] = true
}
}
}
}
return len(affected)
}
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 {
@@ -76,60 +122,88 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
return cs, nil
return rulesImportPreview{
ChangeSet: cs,
Compatible: rulesPackCompatible(pack),
AffectedEventCount: affectedEventCount(cs, cur),
}, 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)
// 分值一变,历史成绩的积分与名次必须全量重算。
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
if err != nil {
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, &apiError{
status: http.StatusInternalServerError,
code: "INTERNAL",
message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
detail: err.Error(),
}
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return res, nil
return rulesImportResult{
ApplyResult: res,
ChangeSet: cs,
Compatible: rulesPackCompatible(pack),
AffectedEventCount: affectedEventCount(cs, cur),
RecalculatedLedgerRows: rows,
}, nil
}
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
// 返回写入的积分流水条数,供导入结果回报。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
if err != nil {
return err
return 0, err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
return 0, err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return err
return 0, err
}
if err := rows.Err(); err != nil {
return err
return 0, 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 := s.bumpStandings(ctx, tx, actorID); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
return nil
defer func() { _ = tx.Rollback() }()
total := 0
for _, eventID := range ids {
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
if err != nil {
return 0, err
}
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return 0, err
}
total += len(score.Ledger)
}
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
@@ -0,0 +1,243 @@
package httpapi_test
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// decodeRaw 解析 doRaw 返回的完整响应体({ok,data} 信封)。
func decodeRaw[T any](t *testing.T, raw []byte) T {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
return decodeData[T](t, env)
}
// exportRulesYAML 取当前规则包(导出与导入共用同一序列化,保证往返可解析)。
func (e *testEnv) exportRulesYAML(t *testing.T, client *http.Client) string {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
return string(body)
}
// publishTwoResults 录入两条成绩并发布,返回两条流水的分值(按单位)。
func (e *testEnv) publishTwoResults(t *testing.T, client *http.Client, ids seedIDs) {
t.Helper()
status, env2 := e.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-rules-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 = e.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-rules-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// ledgerPointsByTeam 读取当前有效流水的「单位 → 分值」。
func (e *testEnv) ledgerPointsByTeam(t *testing.T) map[int64]float64 {
t.Helper()
rows, err := e.ev.Query(`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result'`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := map[int64]float64{}
for rows.Next() {
var team int64
var points float64
if err := rows.Scan(&team, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[team] = points
}
return out
}
// 规则导入后必须回写名次:分值一变,results.rank 也要跟着新规则重算(P0-5)。
func TestRulesImportRewritesRanks(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 模拟名次过期/写坏,导入规则包后应被重算覆盖。
env.exec(t, `UPDATE results SET rank = 99 WHERE event_id = ?`, ids.event)
pack := env.exportRulesYAML(t, client)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
rows, err := env.ev.Query(`SELECT entry_id, rank FROM results WHERE event_id = ? ORDER BY entry_id`, ids.event)
if err != nil {
t.Fatalf("查询名次: %v", err)
}
defer func() { _ = rows.Close() }()
ranks := map[int64]int64{}
for rows.Next() {
var entryID, rank int64
if err := rows.Scan(&entryID, &rank); err != nil {
t.Fatalf("扫描名次: %v", err)
}
ranks[entryID] = rank
}
if ranks[ids.entryA] != 1 || ranks[ids.entryB] != 2 {
t.Fatalf("导入后名次应重算为 A=1 B=2,得到 %+v", ranks)
}
}
// 预览与落地响应必须带上 compatible / affectedEventCount / recalculatedLedgerRows(P1-11)。
func TestRulesImportReturnsContractFields(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 把 M100 的计分通道换成团队模板:制造一个「影响 1 个小项」的差异。
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道,导出内容:\n%s", pack)
}
// 只改模板分值:引用了该模板的小项同样算「被影响」。
pointsChanged := strings.Replace(pack, "points: 10", "points: 11", 1)
if pointsChanged == pack {
t.Fatalf("规则包里没有可替换的分值,导出内容:\n%s", pack)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(pointsChanged), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
pointsPreview := decodeRaw[map[string]any](t, raw)
if count, _ := pointsPreview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("只改模板分值时 affectedEventCount 应为 1,得到 %+v", pointsPreview["affectedEventCount"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
preview := decodeRaw[map[string]any](t, raw)
if preview["compatible"] != true {
t.Fatalf("预览 compatible 应为 true,得到 %+v", preview["compatible"])
}
if count, _ := preview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("预览 affectedEventCount 应为 1,得到 %+v", preview["affectedEventCount"])
}
if channels, _ := preview["channels"].([]any); len(channels) != 1 {
t.Fatalf("预览 channels 应含 1 个小项,得到 %+v", preview["channels"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
applied := decodeRaw[map[string]any](t, raw)
if applied["compatible"] != true {
t.Fatalf("导入结果 compatible 应为 true,得到 %+v", applied["compatible"])
}
if count, _ := applied["affectedEventCount"].(float64); count != 1 {
t.Fatalf("导入结果 affectedEventCount 应为 1,得到 %+v", applied["affectedEventCount"])
}
if rows, _ := applied["recalculatedLedgerRows"].(float64); rows != 2 {
t.Fatalf("导入结果 recalculatedLedgerRows 应为 2,得到 %+v", applied["recalculatedLedgerRows"])
}
// 落地响应也要带差异明细,否则前端会把预览覆盖成「无差异」。
if channels, _ := applied["channels"].([]any); len(channels) != 1 {
t.Fatalf("导入结果 channels 应含 1 个小项,得到 %+v", applied["channels"])
}
// 换成团队模板后分值应变成 15/10,证明重算确实落库。
points := env.ledgerPointsByTeam(t)
if points[ids.teamA] != 15 || points[ids.teamB] != 10 {
t.Fatalf("导入后流水分值应为 A=15 B=10,得到 %+v", points)
}
}
// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
// 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
// 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
brokenEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, broken, brokenCat, ids.teamA, ids.athleteA)
env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
before := env.ledgerPointsByTeam(t)
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道")
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "积分重算失败") {
t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
}
// 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
var code string
if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
JOIN scoring_profiles p ON p.id = c.profile_id
WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
t.Fatalf("查询导入后的计分通道: %v", err)
}
if code != "team_top6" {
t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
}
after := env.ledgerPointsByTeam(t)
if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
}
}
+56 -4
View File
@@ -328,12 +328,17 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
// 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项),
// 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活,
// 重算再插一份导致该通道分数翻倍。这类流水改按「本小项的聚合通道」定位。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
scope := `source_type IN ('result', 'record_bonus') 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)
scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'
AND source_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)))`
args = append(args, categoryID, eventID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
@@ -346,7 +351,33 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
if err != nil {
return domain.EventScore{}, err
}
for _, l := range score.Ledger {
ledger := score.Ledger
if categoryID > 0 {
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
if err != nil {
return domain.EventScore{}, err
}
if hasAggregate {
all, err := computeEventScore(ctx, tx, eventID, 0)
if err != nil {
return domain.EventScore{}, err
}
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
for _, l := range score.Ledger {
if !isAggregateLedger(l) {
ledger = append(ledger, l)
}
}
for _, l := range all.Ledger {
if isAggregateLedger(l) {
ledger = append(ledger, l)
}
}
}
}
for _, l := range 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -358,6 +389,22 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
return score, nil
}
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
// 落库时 entry_id 与 result_id 都为空。
func isAggregateLedger(l domain.LedgerEntry) bool {
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
}
// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
return false, internal(err)
}
return n > 0, nil
}
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
@@ -575,7 +622,12 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
if err != nil {
return resultDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
+120
View File
@@ -0,0 +1,120 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// resultLedger 统计成绩分流水(未作废)的条数与合计。
func (e *testEnv) resultLedger(t *testing.T) (int, float64) {
t.Helper()
var (
count int
total float64
)
if err := e.ev.QueryRow(`SELECT COUNT(*), COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND source_type = 'result'`).Scan(&count, &total); err != nil {
t.Fatalf("统计成绩流水: %v", err)
}
return count, total
}
// 聚合通道的流水 entry_id 为空,按组别重算若只靠 entry_id 定位就作废不掉旧流水,
// 重算再插一份导致分数翻倍。整项发布后反复按组别发布,分数必须保持不变。
func TestRecomputeAggregateLedgerIsIdempotent(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")
// 换成聚合通道:按单位求和后排名,流水不对应单条报名项。
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"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)
publish := func(name string, categoryID *int64) {
t.Helper()
body := map[string]any{"eventId": ids.event}
if categoryID != nil {
body["categoryId"] = *categoryID
}
status, env2 := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("%s应 200,得到 %d %+v", name, status, env2.Error)
}
// 聚合通道两单位各一条流水:15 + 10。
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("%s后成绩流水应仍为 2 条 25 分(不得翻倍),得到 %d 条 %v 分", name, count, total)
}
}
catID := ids.category
publish("整项发布", nil)
publish("按组别发布", &catID)
publish("再次按组别发布", &catID)
}
// 作废一条成绩会改变同组别名次,重算结果必须回写 results.rank,
// 否则奖牌榜(读 rank)与积分榜(读流水)互相矛盾。
func TestVoidResultRewritesRemainingRanks(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,
"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)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
var winnerID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&winnerID); err != nil {
t.Fatalf("读取成绩 id: %v", err)
}
status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/void", winnerID),
map[string]any{"reason": "抢跑"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
}
var rank int
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
t.Fatalf("读取名次: %v", err)
}
if rank != 1 {
t.Fatalf("作废冠军后剩余成绩名次应回写为 1,得到 %d", rank)
}
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
for _, row := range standings {
if row["teamCode"] == "B" {
if row["totalPoints"].(float64) != 10 || row["gold"].(float64) != 1 {
t.Fatalf("作废后 B 应升为第一(10 分 1 金): %+v", row)
}
}
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ func New(opts Options) http.Handler {
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
h = middleware.Authenticate(svc)(h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
@@ -236,6 +236,9 @@ func TestRevisionsListAndDetail(t *testing.T) {
}
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
// 已发布成绩必须先撤销发布才能改(服务端拒绝直接覆盖 published 行),
// 这里直接把状态置回 draft,等价于撤销发布但不额外留一条 unpublish 版本。
env.exec(t, `UPDATE results SET state = 'draft' WHERE event_id = ?`, ids.event)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{