Files
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

216 lines
5.9 KiB
Go

package httpapi_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。
type testEnv struct {
t *testing.T
server *httptest.Server
sys *sql.DB
ev *sql.DB
auth *auth.Service
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWith(t, nil)
}
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
t.Helper()
return newTestEnvPrepared(t, nil, customize)
}
// newTestEnvPrepared 在赛事库迁移之前给测试一次插手的机会(prepare),
// 用于构造「旧版赛事包」现场:先把库停在某条迁移之前、塞进老数据,再走 store.MigrateEvent
// 的真实升级路径——与 main.go 打开旧包后补跑迁移的顺序完全一致。
func newTestEnvPrepared(t *testing.T, prepare func(ev *sql.DB), customize func(*httpapi.Options)) *testEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db")
if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
ev.SetMaxOpenConns(1)
t.Cleanup(func() { _ = ev.Close() })
if prepare != nil {
prepare(ev)
}
if err := store.MigrateEvent(ev); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
opts := httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
}
if customize != nil {
customize(&opts)
}
srv := httptest.NewServer(httpapi.New(opts))
t.Cleanup(srv.Close)
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
}
func (e *testEnv) createUser(username, password, role string) int64 {
e.t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
e.t.Fatalf("生成密码哈希: %v", err)
}
res, err := e.sys.Exec(
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role)
if err != nil {
e.t.Fatalf("创建用户 %s: %v", username, err)
}
id, _ := res.LastInsertId()
return id
}
func (e *testEnv) client() (*http.Client, *cookiejar.Jar) {
e.t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
e.t.Fatalf("创建 cookie jar: %v", err)
}
return &http.Client{Jar: jar}, jar
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiErrorBody `json:"error"`
}
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
}
// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。
func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) {
return e.doH(client, method, path, body, csrf, nil)
}
// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。
func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) {
e.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
e.t.Fatalf("序列化请求体: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, e.server.URL+path, reader)
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if csrf {
token := e.csrfToken(client)
if token == "" {
e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path)
}
req.Header.Set("X-CSRF-Token", token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw)
}
}
return resp.StatusCode, env
}
func (e *testEnv) csrfToken(client *http.Client) string {
u, err := url.Parse(e.server.URL)
if err != nil {
e.t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == auth.CookieCSRF {
return c.Value
}
}
return ""
}
func (e *testEnv) login(client *http.Client, username, password string) envelope {
e.t.Helper()
status, env := e.do(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": username, "password": password}, false)
if status != http.StatusOK || !env.OK {
e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error)
}
return env
}
func decodeData[T any](t *testing.T, env envelope) T {
t.Helper()
var out T
if err := json.Unmarshal(env.Data, &out); err != nil {
t.Fatalf("解析 data: %v (%s)", err, env.Data)
}
return out
}