Files
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

331 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// ---------- 登录有效期(系统级设置)的测试脚手架 ----------
type sessionSettingsDTO struct {
SessionHours int `json:"sessionHours"`
DefaultHours int `json:"defaultSessionHours"`
MinHours int `json:"minHours"`
MaxHours int `json:"maxHours"`
ExpiresAt *string `json:"expiresAt"`
}
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// sysSetting 读系统库 settings 里的键。
func sysSetting(t *testing.T, env *testEnv, key string) (string, bool) {
t.Helper()
var v string
err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false
}
if err != nil {
t.Fatalf("读系统设置 %s: %v", key, err)
}
return v, true
}
// sessionExpiry 取某用户当前会话的到期时间(测试里每个用户只保留一个会话)。
func sessionExpiry(t *testing.T, env *testEnv, username string) time.Time {
t.Helper()
var raw string
if err := env.sys.QueryRow(
`SELECT s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
username).Scan(&raw); err != nil {
t.Fatalf("读 %s 的会话到期时间: %v", username, err)
}
at, err := time.Parse(time.DateTime, raw)
if err != nil {
t.Fatalf("解析到期时间 %q: %v", raw, err)
}
return at
}
// sessionLastSeen 取某用户当前会话的活动时间。
func sessionLastSeen(t *testing.T, env *testEnv, username string) time.Time {
t.Helper()
var raw string
if err := env.sys.QueryRow(
`SELECT COALESCE(s.last_seen_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
username).Scan(&raw); err != nil {
t.Fatalf("读 %s 的会话活动时间: %v", username, err)
}
at, err := time.Parse(time.DateTime, raw)
if err != nil {
t.Fatalf("解析活动时间 %q: %v", raw, err)
}
return at
}
func setSessionExpiry(t *testing.T, env *testEnv, username string, at time.Time) {
t.Helper()
if _, err := env.sys.Exec(
`UPDATE sessions SET expires_at = ?
WHERE user_id = (SELECT id FROM users WHERE username = ?)`,
at.UTC().Format(time.DateTime), username); err != nil {
t.Fatalf("改 %s 的会话到期时间: %v", username, err)
}
}
func adminClient(t *testing.T, env *testEnv) *http.Client {
t.Helper()
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
return client
}
// ---------- 默认值与落库 ----------
// 面板默认 24 小时:迁移种下的历史占位值 12 从没被任何代码读过,按「未配置」处理;
// 写完之后落到系统库,重启(新建 Service)也读得到。
func TestSessionSettingsDefaultsAndWrite(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
status, body := env.do(client, http.MethodGet, "/api/system/session", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读取登录有效期应 200,得到 %d %+v", status, body.Error)
}
got := decodeData[sessionSettingsDTO](t, body)
if got.SessionHours != 24 || got.DefaultHours != 24 || got.MinHours != 1 || got.MaxHours != 720 {
t.Fatalf("默认值/范围不符:%+v", got)
}
if got.ExpiresAt == nil || !strings.HasSuffix(*got.ExpiresAt, "Z") {
t.Fatalf("应回带当前会话的到期时间(RFC3339 UTC),得到 %v", got.ExpiresAt)
}
if v, ok := sysSetting(t, env, auth.SessionHoursKey); !ok || v != "12" {
t.Fatalf("初始库里应是迁移种下的 12,得到 %q(存在=%v)", v, ok)
}
status, body = env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 72}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("写入应 200,得到 %d %+v", status, body.Error)
}
if got := decodeData[sessionSettingsDTO](t, body); got.SessionHours != 72 {
t.Fatalf("应回显 72,得到 %+v", got)
}
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "72" {
t.Fatalf("落库应为 72,得到 %q", v)
}
// 来源标记:有这个键,读取时就不会把值当成迁移占位值。
if v, _ := sysSetting(t, env, auth.SessionHoursSourceKey); v != auth.SessionHoursSourcePanel {
t.Fatalf("应登记来源 %q,得到 %q", auth.SessionHoursSourcePanel, v)
}
// 重启等价物:新起一个 Service 读同一个库,仍是 72h。
restarted := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
if restarted.SessionTTL() != 72*time.Hour {
t.Fatalf("重启后应仍读 72h,得到 %s", restarted.SessionTTL())
}
}
// 非法值一律 400 + 中文提示,且不落库;读写都是 admin-only。
func TestSessionSettingsValidationAndPermission(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
for _, tc := range []struct {
name string
body map[string]any
}{
{"缺字段", map[string]any{}},
{"过小", map[string]any{"sessionHours": 0}},
{"负数", map[string]any{"sessionHours": -3}},
{"过大", map[string]any{"sessionHours": 721}},
} {
status, body := env.do(client, http.MethodPut, "/api/system/session", tc.body, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, body.Error)
}
if !strings.Contains(body.Error.Message, "登录有效期") {
t.Fatalf("%s 的中文提示应说明是登录有效期,得到 %q", tc.name, body.Error.Message)
}
}
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "12" {
t.Fatalf("非法值不该落库,库里是 %q", v)
}
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读登录有效期应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusForbidden {
t.Fatalf("recorder 写登录有效期应 403,得到 %d", status)
}
}
// ---------- 滑动续期 ----------
// 会话只要还在被使用就顺延;节流生效时不会每次请求都写 expires_at。
func TestSlidingRenewalThrottledOverHTTP(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 模拟「已经消耗掉大半寿命」:剩 23h58m(不足 24h-1m 的节流窗口)。
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(23*time.Hour+58*time.Minute))
before := sessionExpiry(t, env, "admin")
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("请求应成功,得到 %d %+v", status, body.Error)
}
renewed := sessionExpiry(t, env, "admin")
if diff := renewed.Sub(before); diff < time.Minute || diff > 3*time.Minute {
t.Fatalf("应把到期时间顺延约 2 分钟(到 now+24h),实际 %s → %s(差 %s)",
before.Format(time.DateTime), renewed.Format(time.DateTime), diff)
}
// 紧接着的多次请求都落在节流窗口内:expires_at 与 last_seen_at 都一动不动
// (改动前是每个请求写一次 last_seen_at,现在最多每分钟一次)。
seen := sessionLastSeen(t, env, "admin")
for i := 0; i < 5; i++ {
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
t.Fatalf("第 %d 次请求应成功,得到 %d", i, status)
}
if got := sessionExpiry(t, env, "admin"); !got.Equal(renewed) {
t.Fatalf("节流窗口内不该再写 expires_at:%s → %s",
renewed.Format(time.DateTime), got.Format(time.DateTime))
}
if got := sessionLastSeen(t, env, "admin"); !got.Equal(seen) {
t.Fatalf("节流窗口内不该再写 last_seen_at:%s → %s",
seen.Format(time.DateTime), got.Format(time.DateTime))
}
}
}
// 改设置不动在线的人:旧会话的 expires_at 不变、请求照常成功,下一次使用时按新有效期延长。
func TestSessionHoursChangeKeepsOnlineSessions(t *testing.T) {
env := newTestEnv(t)
boss := adminClient(t, env)
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
t.Fatalf("先设成 1 小时应 200,得到 %d %+v", status, body.Error)
}
env.createUser("recorder", "recorder-pass", "recorder")
worker, _ := env.client()
env.login(worker, "recorder", "recorder-pass")
before := sessionExpiry(t, env, "recorder")
// 管理员把有效期改成 72 小时。
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 72}, true); status != http.StatusOK {
t.Fatalf("改成 72 小时应 200,得到 %d %+v", status, body.Error)
}
if after := sessionExpiry(t, env, "recorder"); !after.Equal(before) {
t.Fatalf("改设置不该动已登录会话的 expires_at:%s → %s",
before.Format(time.DateTime), after.Format(time.DateTime))
}
// 旧会话仍被认,而且拿到的是新的(更长的)到期时间。
status, body := env.do(worker, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("旧会话不该被踢下线,得到 %d %+v", status, body.Error)
}
me := decodeData[map[string]any](t, body)
if me["role"] != "recorder" {
t.Fatalf("旧会话应仍是 recorder 身份,得到 %v", me["role"])
}
if me["sessionExpiresAt"] == nil {
t.Fatal("me 应回带当前会话的到期时间")
}
after := sessionExpiry(t, env, "recorder")
if diff := after.Sub(before); diff < 70*time.Hour {
t.Fatalf("下一次使用应按新有效期顺延到 ~72h,实际 %s → %s",
before.Format(time.DateTime), after.Format(time.DateTime))
}
}
// 到期校验不能被改坏:手工把 expires_at 改到 1 分钟前,写请求必须 401 AUTH_REQUIRED。
func TestExpiredSessionReturnsAuthRequired(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(-time.Minute))
status, body := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"event": map[string]any{"name": "秋季运动会"}}, true)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("过期会话写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
}
// ---------- 安全口径没有被放宽 ----------
func TestSessionSettingsKeepsSecurityPosture(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
// 登录下发的 Cookie 仍带 HttpOnly + SameSite=Lax;CSRF Cookie 仍可被前端读取。
resp, err := http.Post(env.server.URL+"/api/auth/login", "application/json",
strings.NewReader(`{"username":"admin","password":"admin-pass"}`))
if err != nil {
t.Fatalf("登录: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var session, csrf *http.Cookie
for _, c := range resp.Cookies() {
switch c.Name {
case auth.CookieSession:
session = c
case auth.CookieCSRF:
csrf = c
}
}
if session == nil || !session.HttpOnly || session.SameSite != http.SameSiteLaxMode || session.Secure {
t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax:%+v", session)
}
if csrf == nil || csrf.HttpOnly {
t.Fatalf("CSRF Cookie 应可被前端读取(非 HttpOnly):%+v", csrf)
}
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// CSRF 双提交仍然拦写:不带 X-CSRF-Token 的写请求一律 403。
if status, body := env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 2}, false); status != http.StatusForbidden ||
body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("缺 CSRF 头的写请求应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
}
// must_change_password 仍然拦写操作。
env.createUser("fresh", "fresh-pass", "admin")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE username = 'fresh'`); err != nil {
t.Fatalf("置 must_change_password: %v", err)
}
fresh, _ := env.client()
env.login(fresh, "fresh", "fresh-pass")
if status, body := env.do(fresh, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 2}, true); status != http.StatusForbidden ||
body.Error == nil || body.Error.Message != "请先修改初始密码" {
t.Fatalf("待改密账号的写请求应被拦下,得到 %d %+v", status, body.Error)
}
// 未登录访客读不到登录有效期。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
t.Fatalf("访客读登录有效期应 403,得到 %d", status)
}
}