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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user