fix(entry): 回到录入页时记住上次的小项(按 id);feat: 新增「清空本组 / 本批成绩」;版本 dev_0.5.12
## 一、修「改小项显示名称后,撤销发布按钮消失」 **根因不是改名字。** 我一开始怀疑「某处按 name 关联」,验下来那条路是干净的: 加载录入页报名行的查询、成绩查询、引擎读数据全是按 event_id / category_id / code 关联的 (并补了一条「小项名 + 组别名一起改」的回归用例,证明录入页状态 / 流水 / 榜单逐字节不变)。 真机制:**进入录入页时不记得上次录的是哪个小项**,兜底挑「第一个**待录入**(pending)的小项」。 而**刚发布完的小项状态已经是 published**,恰好被这条兜底排除 → 回去时右侧换成了**另一个小项**, 而「撤销发布」按钮只按「当前表里有没有已发布的行」显示,自然就不在。 ⇒ 现场看到的「左栏仍显示已发布、单位成绩仍有加分、但按钮没了」**三条本来不矛盾 —— 它们说的是两个不同的小项**。 **修法**:`pickInitialEventId()` 记住上次录入的小项(**按 id** 存 localStorage), 优先级 = URL 查询参数 → 上次录入的(**不排除已发布**)→ 第一个 pending → 列表第一个; 记忆失效(小项被删、隐私模式读不到存储)一律退回原兜底,不把人挡在录入页外。 **纯前端,零数据迁移。** 真机复现与复验(CDP 驱动真实浏览器): - 修前:「离开再回来 → 换成了别的小项、没有撤销发布按钮」; - 修后:「离开再回来 → 仍是那个小项、按钮在」。 ## 二、新增「清空本组 / 本批成绩」(危险操作) `POST /api/results/clear`(recorder 及以上 / CSRF / 幂等): - `heat` **省略 = 清整组**、数字 = 该批次、显式 `null` = 只清未分批的行(三种要能分清); - `confirm` 必须为 `true`(**服务端兜底**「没勾选不让提交」,不只是前端拦); - **不提供「清空整个小项」** —— 爆炸半径太大; - 语义 = **先撤销发布 + 再清内容**:成绩行整行归零 → **清整组时一并清掉「团体名次」** (否则会出现「清空了、榜上还有分」)→ 作废旧流水并按剩余已发布内容重写 → **小项状态对称退回**(只在原为 published 时降级,正好避免「左栏写着已发布却没有已发布成绩」) → 写审计 + 推榜。 界面:工具栏「清空本组」常驻;「清空本批」只在筛了批次时出现(没筛批次时它等于清整组,不重复给); 两粒都是危险色,示例数据模式 / 访客只读时禁用并说明原因。 **与「撤销发布」的区别**(界面上都写清了,很容易混): - **撤销发布**:成绩内容**保留**,只把分收回; - **清空**:成绩内容**一起清掉**,分也收回。 ## 三、顺带:按名字关联的同类扫描 扫了 13 处「按名字关联」的地方并逐条判定,结论: **数据关联一律按 id/code**;只有当「人写的名字要在表里对齐」时才按名字 (导入报名表里的组别名、Excel 工作表名、缺证件号时的同名兜底)—— 那几处是**有意为之**、报错也能照着改,**未改**。 ## 四、验证 - 全量自检两轮**全绿、退出码 0**:服务端 14 包 / 前端 **974 例** 0 fail / Console 96 例 0 fail。 - 新增用例:`event_rename_publish_test.go`(改名后状态/流水/榜单逐字节不变)、 `entry-initial-event.test.ts`(8 例)、`results_clear_test.go`、`entry-clear.test.ts`。 ## 版本 dev_0.5.11 → dev_0.5.12(无数据迁移)。
This commit is contained in:
1 parent
7a64e83ecf
commit
72362239c6
20 files changed
+1448
-18
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// entryStates 读成绩录入接口,返回「报名项 id → 成绩状态」。
|
||||
func entryStates(t *testing.T, env *testEnv, client *http.Client, eventID int64) map[int64]string {
|
||||
t.Helper()
|
||||
board := getEntryBoard(t, env, client, eventID)
|
||||
out := make(map[int64]string, len(board.Entries))
|
||||
for _, row := range board.Entries {
|
||||
entryID, _ := row["id"].(float64)
|
||||
result, _ := row["result"].(map[string]any)
|
||||
state, _ := result["state"].(string)
|
||||
out[int64(entryID)] = state
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 改「显示名称」不该影响任何功能:小项 / 组别名改掉之后,录入页读到的成绩状态、
|
||||
// 积分流水、榜单必须逐字节不变。
|
||||
//
|
||||
// 这条用例的现场来历:主人把一个小项的显示名称改掉、回录入页,发现「撤销发布」按钮不见了
|
||||
// (右侧其实换成了另一个小项——进入录入页的默认选中把它排除了,与改名无关,另有用例守着)。
|
||||
// 为了把「改名会不会断链」这件事一次问清,这里把**所有按名字关联**的可能都覆盖上:
|
||||
// 小项名与组别名都改掉,再对比三份读数。
|
||||
func TestRenameEventAndCategoryKeepsEverythingIntact(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "rename",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
statesBefore := entryStates(t, env, client, ids.event)
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
if statesBefore[ids.entryA] != "published" || statesBefore[ids.entryB] != "published" {
|
||||
t.Fatalf("前置:两条成绩都应是 published,得到 %v", statesBefore)
|
||||
}
|
||||
|
||||
// ① 改小项显示名称(其余字段照原样回写,与基础数据页的整表覆盖语义一致)。
|
||||
ev := decodeData[map[string]any](t, func() envelope {
|
||||
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", ids.event), nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读小项应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
return resp
|
||||
}())
|
||||
ev["name"] = "100米跑(改名后)"
|
||||
if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), ev, true); status != http.StatusOK {
|
||||
t.Fatalf("改小项名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
// ② 改组别名(就地更新,不删不插)。
|
||||
if status, resp := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||
map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true); status != http.StatusOK {
|
||||
t.Fatalf("改组别名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
if got := entryStates(t, env, client, ids.event); got[ids.entryA] != "published" || got[ids.entryB] != "published" {
|
||||
t.Fatalf("改名后成绩状态不该变:%v", got)
|
||||
}
|
||||
if got, want := ledgerRowsOf(t, env, ids.event, nil), ledgerBefore; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("改名后流水不该变:\n%v\n%v", got, want)
|
||||
}
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("改名后榜单不该变:\n%s\n%s", got, standingsBefore)
|
||||
}
|
||||
// 小项自身的进度状态(左栏「已发布 / 待录入」读的就是它)也不能被改名带偏。
|
||||
var status string
|
||||
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil {
|
||||
t.Fatalf("读小项状态: %v", err)
|
||||
}
|
||||
if status != "published" {
|
||||
t.Fatalf("改名后小项状态应仍是 published,得到 %q", status)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,22 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
})
|
||||
}
|
||||
|
||||
// clearResultsHandler 是「清空当前组 / 批次成绩」(POST /api/results/clear)。
|
||||
//
|
||||
// 与 publish / unpublish 同档权限(recorder 及以上 + CSRF):现场就是同一批人在录成绩。
|
||||
// 用 decodeJSONPresence 是为了分清「请求体里没有 heat」(清整个组别)与
|
||||
// 「heat 是 null」(只清未分批的行)——两者只差一个键,动作范围差一整组。
|
||||
func (s *server) clearResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in clearResultsPayload
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
return s.clearResults(r.Context(), in, keys.has("heat"), middlewareUserID(r))
|
||||
})
|
||||
}
|
||||
|
||||
// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。
|
||||
func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in publishPayload
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 清空「当前组 / 批次」的成绩(POST /api/results/clear)。
|
||||
//
|
||||
// 语义(主人定的):**先撤销发布,再把成绩内容清空**,回到「没录过」的样子——
|
||||
// - 已发布的收回(state 退回 draft)、该范围产生的积分流水作废(大屏与榜单跟着回退);
|
||||
// - 名次 / 成绩值 / 成绩文本 / 单位 / 并列 / 状态 / 备注全部清掉;
|
||||
// - 整个组别(scope=group)时,连同该组别的「按单位录入的团体名次」一起清掉:
|
||||
// 它同样是这一组的成绩内容,不清就变成「清空了榜上却还有分」(那批分没有成绩行,
|
||||
// 重算时会原样再写一遍)。
|
||||
//
|
||||
// 与「撤销发布」的区别必须守住:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。
|
||||
// 所以这是危险操作:必须显式勾选确认(服务端也要求 confirm=true)、全程写审计。
|
||||
//
|
||||
// 批次(heat)只是录入 / 展示分组,不参与算分,所以这里按 heat 过滤的只有「成绩行」:
|
||||
// 团体名次不分批次,清「本批」时一律不动它。
|
||||
|
||||
// clearResultsPayload 是 POST /api/results/clear 的请求体。
|
||||
//
|
||||
// heat 用「在不在请求体里」区分三件事,所以必须配 decodeJSONPresence 读:
|
||||
// 不传 = 整个组别(含未分批的行);传数字 = 只清这一批次;传 null = 只清「未分批」的行。
|
||||
// 用 *int64 单看值分不出「不传」与「传 null」——前者是清整组,后者只清未分批,差得很远。
|
||||
type clearResultsPayload struct {
|
||||
publishPayload
|
||||
Heat *int64 `json:"heat"`
|
||||
// Confirm 必须为 true:这是「勾选了『我知道这会清掉已发布的分数』」在协议上的落点。
|
||||
// 前端禁用确认键只是界面防呆,服务端不能靠它。
|
||||
Confirm bool `json:"confirm"`
|
||||
// Reason 是操作员填的原因(可空);写进流水作废原因与审计,便于事后复盘。
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// 清空范围的三种取值:与请求体里 heat 的三种写法一一对应。
|
||||
const (
|
||||
clearScopeGroup = "group" // 整个组别(含未分批的行)
|
||||
clearScopeBatch = "batch" // 指定批次
|
||||
clearScopeUnbatched = "unbatched" // 只清「未分批」的行
|
||||
)
|
||||
|
||||
// clearResultsResultDTO 是清空回执:清了多少条、其中多少条原先已发布、作废了多少条流水。
|
||||
type clearResultsResultDTO struct {
|
||||
Scope string `json:"scope"`
|
||||
Heat *int64 `json:"heat"`
|
||||
// Cleared 是本次被清空的成绩行数;Published 是其中原先处于已发布状态的条数。
|
||||
Cleared int `json:"cleared"`
|
||||
Published int `json:"published"`
|
||||
// VoidedLedger 是本次作废的积分流水条数;TeamRanks 是连同清掉的团体手工名次条数。
|
||||
VoidedLedger int `json:"voidedLedger"`
|
||||
TeamRanks int `json:"teamRanks"`
|
||||
}
|
||||
|
||||
// clearResults 清空指定范围内的成绩。heatPresent 表示请求体里到底有没有 heat 这个键。
|
||||
func (s *server) clearResults(ctx context.Context, in clearResultsPayload, heatPresent bool, userID int64) (clearResultsResultDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return clearResultsResultDTO{}, badRequest("eventId 必填", nil)
|
||||
}
|
||||
if in.CategoryID == nil || *in.CategoryID <= 0 {
|
||||
// 故意不提供「清空整个小项」:那条路的爆炸半径是几十条成绩乘上组别数,
|
||||
// 而界面上每一次操作看到的都是「当前这一组」。要清整项就逐组清。
|
||||
return clearResultsResultDTO{}, badRequest("categoryId 必填:清空以「小项 + 组别」为界", nil)
|
||||
}
|
||||
if !in.Confirm {
|
||||
return clearResultsResultDTO{}, badRequest("请先勾选「我知道这会清掉已发布的分数」再提交", nil)
|
||||
}
|
||||
scope := clearScopeGroup
|
||||
if heatPresent {
|
||||
switch {
|
||||
case in.Heat == nil:
|
||||
scope = clearScopeUnbatched
|
||||
case *in.Heat < 1 || *in.Heat > maxEntryHeat:
|
||||
return clearResultsResultDTO{}, badRequest("heat 需要是 1-999 的整数,或传 null 只清未分批的行", nil)
|
||||
default:
|
||||
scope = clearScopeBatch
|
||||
}
|
||||
}
|
||||
categoryID := *in.CategoryID
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 范围里的报名项:整组 = 该组别全部;按批次 = 该组别里 heat 命中的那些。
|
||||
entryScope := `SELECT id FROM entries WHERE event_id = ? AND category_id = ?`
|
||||
args := []any{in.EventID, categoryID}
|
||||
switch scope {
|
||||
case clearScopeBatch:
|
||||
entryScope += ` AND heat = ?`
|
||||
args = append(args, *in.Heat)
|
||||
case clearScopeUnbatched:
|
||||
entryScope += ` AND heat IS NULL`
|
||||
}
|
||||
|
||||
// 清空前的读数:条数与已发布条数进回执、审计与变更历史,
|
||||
// 让「清了多少条、其中多少条动过分」有据可查(现场事后只认这句话)。
|
||||
var cleared, published int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*), COALESCE(SUM(state = 'published'), 0) FROM results
|
||||
WHERE entry_id IN (`+entryScope+`)`, args...).Scan(&cleared, &published); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
// 团体名次(只在清整个组别时算数)也是这一组的成绩内容,先数出来:
|
||||
// 纯团体名次的项目(球类那种)一条 results 行都没有,只有它才是「可清的东西」。
|
||||
teamRanks := 0
|
||||
if scope == clearScopeGroup {
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`,
|
||||
in.EventID, categoryID).Scan(&teamRanks); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
// 「什么都没有」要明确回答,而不是静默回一句「清空 0 条」——操作员多半看错了批次 / 组别,
|
||||
// 提示成功会让他以为已经清干净了。判据是这一组此刻还有没有任何成绩痕迹:
|
||||
// 成绩行、团体名次、以及还没作废的流水(只有它才能证明「榜上还挂着分」)。
|
||||
validLedger := s.validLedgerPoints(ctx, tx, in.EventID, categoryID)
|
||||
if cleared == 0 && teamRanks == 0 && validLedger == 0 {
|
||||
return clearResultsResultDTO{}, badRequest("该范围没有成绩可清空(这一组或这一批次还没录过)", nil)
|
||||
}
|
||||
|
||||
// 成绩内容整行归零:回到「没录过」的样子(录入页把「有行但全空」与「没有行」显示成同一件事)。
|
||||
// state 必须退回 draft——留在 published 就成了「榜上没分、表格却锁着」的半成品。
|
||||
// points_override 一并清掉:它是已停用的历史列,留着只会让这一条之后既发不出去也撤不回来。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results
|
||||
SET state = 'draft', rank = NULL, result_text = NULL, result_value = NULL, unit = NULL,
|
||||
is_tie = 0, status = 'ok', note = NULL, points_override = NULL,
|
||||
verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
|
||||
WHERE entry_id IN (`+entryScope+`)`, args...); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
// 团体名次只在「清整个组别」时动:它按 (小项, 组别, 单位) 存,与批次无关,
|
||||
// 清一批却把它抹掉会连带清掉别的批次的团体分。
|
||||
if scope == clearScopeGroup && teamRanks > 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`, in.EventID, categoryID); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 作废旧流水并按「剩下的已发布内容」重写:撤销发布走的是同一条路(先算后作废,流水只追加),
|
||||
// 所以清空之后榜上剩下的分,与「这些成绩从没发布过」逐字节一致。
|
||||
voidedBefore := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID)
|
||||
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, categoryID, userID, clearVoidReason(in.Reason, scope)); err != nil {
|
||||
return clearResultsResultDTO{}, err
|
||||
}
|
||||
voidedAfter := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID)
|
||||
|
||||
// 小项进度与撤销发布对称地退回:这一下把该组已发布的成绩全撤了,小项状态不跟着退,
|
||||
// 左栏就会写着「已发布」而这一组其实一条已发布成绩都没有——正是现场最容易被误导的那种假象。
|
||||
// 与撤销发布同一口径:只在整项此前被标成 published 时降级,pending / ongoing 是人工设的进度,不覆盖。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE events SET status = 'finished' WHERE id = ? AND status = 'published'`,
|
||||
in.EventID); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
results, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
|
||||
if err != nil {
|
||||
return clearResultsResultDTO{}, err
|
||||
}
|
||||
// 变更历史:action 只有 publish / unpublish / edit 三种(0008 的 CHECK),
|
||||
// 清空本质上是「撤销发布 + 清内容」,归到 unpublish 并在 message 里写明清了多少条。
|
||||
// 为它新加一个 action 取值要重建整张表,代价与收益不成比例。
|
||||
if err := recordRevision(ctx, tx, in.EventID, "unpublish", results, userID,
|
||||
clearRevisionNote(scope, in.Heat, cleared, published, teamRanks)); err != nil {
|
||||
return clearResultsResultDTO{}, err
|
||||
}
|
||||
if err := recordChanges(ctx, tx, "result", results, "upsert", userID); err != nil {
|
||||
return clearResultsResultDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return clearResultsResultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return clearResultsResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
out := clearResultsResultDTO{
|
||||
Scope: scope, Heat: in.Heat, Cleared: cleared, Published: published,
|
||||
VoidedLedger: voidedAfter - voidedBefore, TeamRanks: teamRanks,
|
||||
}
|
||||
// 审计:谁在什么时候清空了哪一组 / 哪一批、清掉多少条、作废多少条流水。
|
||||
// 这几项都要有——「变更历史里能查到谁在什么时候清空了哪一组」是主人点名的要求。
|
||||
s.audit(ctx, "result.clear", "event", in.EventID, nil, map[string]any{
|
||||
"eventId": in.EventID, "categoryId": categoryID, "scope": scope, "heat": in.Heat,
|
||||
"cleared": cleared, "published": published, "voidedLedger": out.VoidedLedger,
|
||||
"teamRanks": teamRanks, "reason": strings.TrimSpace(in.Reason),
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// voidedLedgerCount 数该 (小项, 组别) 范围内已作废的流水条数。
|
||||
//
|
||||
// 重算内部是「先作废、再按当前已发布内容重写」,所以前后两次计数的差值就是本次作废掉的条数。
|
||||
// 作废是「改标记」而不是删行,历史累积的作废行不会干扰:差值只反映这一次。
|
||||
func (s *server) voidedLedgerCount(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) int {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM score_ledger
|
||||
WHERE voided = 1 AND source_type IN ('result', 'record_bonus') AND event_id = ?
|
||||
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'))`,
|
||||
eventID, categoryID).Scan(&n); err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// validLedgerPoints 数该 (小项, 组别) 范围内还没作废的流水分合计。
|
||||
// 判据与 writeLedgerTx 的作废范围逐字一致:成绩分 / 破纪录加分,含「无报名项归属」的
|
||||
// 团体名次与聚合流水(它们挂在 event 上,靠 category 分不出来,只能整项算)。
|
||||
func (s *server) validLedgerPoints(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) float64 {
|
||||
var points float64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(points), 0) FROM score_ledger
|
||||
WHERE voided = 0 AND source_type IN ('result', 'record_bonus') AND event_id = ?
|
||||
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'))`,
|
||||
eventID, categoryID).Scan(&points); err != nil {
|
||||
return 0
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// clearVoidReason 是流水作废原因;操作员填了原因就带上,便于事后复盘。
|
||||
func clearVoidReason(reason, scope string) string {
|
||||
base := "清空成绩"
|
||||
if scope == clearScopeBatch || scope == clearScopeUnbatched {
|
||||
base += "(当前批次)"
|
||||
}
|
||||
if trimmed := strings.TrimSpace(reason); trimmed != "" {
|
||||
return base + ":" + trimmed
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// clearRevisionNote 是变更历史里那句话:说清范围、条数与「已发布的分被收回」。
|
||||
func clearRevisionNote(scope string, heat *int64, cleared, published, teamRanks int) string {
|
||||
word := "本组"
|
||||
switch scope {
|
||||
case clearScopeBatch:
|
||||
word = "第 " + strconv.FormatInt(*heat, 10) + " 批"
|
||||
case clearScopeUnbatched:
|
||||
word = "未分批"
|
||||
}
|
||||
note := "清空" + word + "成绩:" + strconv.Itoa(cleared) + " 条(其中已发布 " +
|
||||
strconv.Itoa(published) + " 条,分数已收回)"
|
||||
if teamRanks > 0 {
|
||||
note += ",团体名次 " + strconv.Itoa(teamRanks) + " 条一并清空"
|
||||
}
|
||||
return note
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clearResults 走真实接口清空指定范围,返回响应体。
|
||||
func clearResults(t *testing.T, env *testEnv, client *http.Client, body map[string]any, wantStatus int) map[string]any {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
|
||||
if status != wantStatus {
|
||||
t.Fatalf("清空应 %d,得到 %d %+v", wantStatus, status, env2.Error)
|
||||
}
|
||||
if wantStatus != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
return decodeData[map[string]any](t, env2)
|
||||
}
|
||||
|
||||
// validLedgerPoints 数该小项当前有效流水的合计分。
|
||||
func validLedgerPoints(t *testing.T, env *testEnv, eventID int64) int {
|
||||
t.Helper()
|
||||
return countRows(t, env,
|
||||
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger
|
||||
WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, eventID)
|
||||
}
|
||||
|
||||
// 清空本组:已发布的分被收回(榜单分数下降)、成绩内容回到「没录过」、写审计与变更历史。
|
||||
//
|
||||
// 这是主人点名的危险操作入口:撤销发布保留成绩内容,清空连内容一起清掉,两者不能混为一谈。
|
||||
func TestClearGroupResultsWipesScoresAndContent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "clear-group",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 前置:榜上确有名次分(前两名 10 + 8 = 18),且两条都是已发布。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||
t.Fatalf("前置:A 队应有 10 分,得到 %v", got)
|
||||
}
|
||||
if got := validLedgerPoints(t, env, ids.event); got != 18 {
|
||||
t.Fatalf("前置:有效流水应合计 18 分,得到 %d", got)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
|
||||
|
||||
body := clearResults(t, env, client, map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "confirm": true, "reason": "现场重赛",
|
||||
}, http.StatusOK)
|
||||
// 回执要说清清了几条、其中几条原先已发布、作废了几条流水。
|
||||
if body["scope"] != "group" || body["cleared"] != float64(2) || body["published"] != float64(2) {
|
||||
t.Fatalf("回执不对: %+v", body)
|
||||
}
|
||||
if body["voidedLedger"] != float64(2) {
|
||||
t.Fatalf("应作废 2 条流水(两条名次分各一条),得到 %v", body["voidedLedger"])
|
||||
}
|
||||
|
||||
// ① 分数收回:榜单回到 0,有效流水一条不剩,作废行保留(审计痕迹)。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("清空后 A 队应 0 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("清空后 B 队应 0 分,得到 %v", got)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 1`, 2, ids.event)
|
||||
|
||||
// ② 成绩内容清空(回到「没录过」):名次 / 成绩值 / 文本 / 状态 / 备注全清,state 退回 draft。
|
||||
assertCount(t, env,
|
||||
`SELECT COUNT(*) FROM results
|
||||
WHERE event_id = ? AND state = 'draft' AND rank IS NULL AND result_value IS NULL
|
||||
AND result_text IS NULL AND unit IS NULL AND note IS NULL AND status = 'ok'`, 2, ids.event)
|
||||
// 录名次的那一列也要跟着走:清空后不该还留着旧名次。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event)
|
||||
|
||||
// ③ 录入页读到的这一组不再有「已发布」的行(按钮与锁定都据此判断)。
|
||||
board := entryStates(t, env, client, ids.event)
|
||||
if board[ids.entryA] != "draft" || board[ids.entryB] != "draft" {
|
||||
t.Fatalf("清空后成绩状态应是 draft,得到 %v", board)
|
||||
}
|
||||
|
||||
// ④ 小项进度跟着退回:左栏不能一边写着「已发布」、一边一条已发布成绩都没有。
|
||||
var status string
|
||||
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil {
|
||||
t.Fatalf("读小项状态: %v", err)
|
||||
}
|
||||
if status == "published" {
|
||||
t.Fatalf("清空后小项状态不该还是 published(左栏会显示「已发布」却没有已发布成绩)")
|
||||
}
|
||||
|
||||
// ⑤ 审计:谁在什么时候清空了哪一组,连带条数。
|
||||
var action, payload string
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT action, after_json FROM audit_logs WHERE action = 'result.clear'`).Scan(&action, &payload); err != nil {
|
||||
t.Fatalf("应写一条 result.clear 审计: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"categoryId":`, `"scope":"group"`, `"cleared":2`, `"published":2`, `现场重赛`} {
|
||||
if !strings.Contains(payload, want) {
|
||||
t.Fatalf("审计载荷应含 %s,得到 %s", want, payload)
|
||||
}
|
||||
}
|
||||
// ⑥ 变更历史:清空按「撤销发布」记一条,message 里写明清了多少条。
|
||||
var message string
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT message FROM result_revisions WHERE event_id = ? AND action = 'unpublish'
|
||||
ORDER BY id DESC LIMIT 1`, ids.event).Scan(&message); err != nil {
|
||||
t.Fatalf("应写一条 unpublish 修订: %v", err)
|
||||
}
|
||||
if !strings.Contains(message, "清空本组成绩:2 条") {
|
||||
t.Fatalf("修订文案应写明清空条数,得到 %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空本批:只清当前批次,别的批次的已发布成绩与分数原样保留。
|
||||
func TestClearBatchKeepsOtherBatches(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 6)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 第 1 批:前 3 条;第 2 批:后 3 条。两批都录成绩并发布。
|
||||
for i, entryID := range entries {
|
||||
heat := int64(1)
|
||||
if i >= 3 {
|
||||
heat = 2
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{entryID}, "heat": heat,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
times := make([]float64, 6)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 6, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
// 前置:6 条已发布,前六名 10+8+6+3+2+1 = 30 分。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 6, ids.event)
|
||||
if got := validLedgerPoints(t, env, ids.event); got != 30 {
|
||||
t.Fatalf("前置:有效流水应 30 分,得到 %d", got)
|
||||
}
|
||||
|
||||
body := clearResults(t, env, client, map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "heat": 1, "confirm": true,
|
||||
}, http.StatusOK)
|
||||
if body["scope"] != "batch" || body["cleared"] != float64(3) || body["published"] != float64(3) {
|
||||
t.Fatalf("回执不对: %+v", body)
|
||||
}
|
||||
|
||||
// 第 1 批清零,第 2 批原样还在。
|
||||
for i := 0; i < 3; i++ {
|
||||
var state string
|
||||
if err := env.ev.QueryRow(`SELECT state FROM results WHERE entry_id = ?`, entries[i]).Scan(&state); err != nil {
|
||||
t.Fatalf("读第 1 批状态: %v", err)
|
||||
}
|
||||
if state != "draft" {
|
||||
t.Fatalf("第 1 批第 %d 条应退回草稿,得到 %s", i+1, state)
|
||||
}
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 3, ids.event)
|
||||
// 剩下第 2 批三个人:第 1、2、3 名 → 10 + 8 + 6 = 24 分(名次整体往前挪,与「只发布过这三条」一致)。
|
||||
if got := validLedgerPoints(t, env, ids.event); got != 24 {
|
||||
t.Fatalf("清掉一批后有效流水应 24 分,得到 %d", got)
|
||||
}
|
||||
// 榜单分数必须真的下降:清空前 30 分,清掉一批后只剩第 2 批那三个人(10+8+6=24)。
|
||||
if got := teamPoints(t, env, client, ids.teamA) + teamPoints(t, env, client, ids.teamB); got != 24 {
|
||||
t.Fatalf("清批后两单位合计应 24 分,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 没勾选确认不许提交:服务端也拦(前端禁用确认键只是界面防呆)。
|
||||
func TestClearRequiresConfirmCheckbox(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "clear-confirm",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
for _, body := range []map[string]any{
|
||||
{"eventId": ids.event, "categoryId": ids.category},
|
||||
{"eventId": ids.event, "categoryId": ids.category, "confirm": false},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("没勾选确认应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "勾选") {
|
||||
t.Fatalf("400 应说清要先勾选,得到 %+v", env2.Error)
|
||||
}
|
||||
}
|
||||
// 一条都不能被动过。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
|
||||
if got := validLedgerPoints(t, env, ids.event); got != 10 {
|
||||
t.Fatalf("被拦下的请求不该动流水,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空要按「小项 + 组别」为界:缺 categoryId 一律 400(没有「一键清空整项」这条危险路)。
|
||||
func TestClearRequiresCategory(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
for _, body := range []map[string]any{
|
||||
{"eventId": ids.event, "confirm": true},
|
||||
{"eventId": ids.event, "categoryId": 0, "confirm": true},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("缺 categoryId 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "categoryId") {
|
||||
t.Fatalf("400 应点名 categoryId,得到 %+v", env2.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空范围给明确回答,而不是静默「清空 0 条」——操作员多半看错了批次 / 组别。
|
||||
func TestClearEmptyScopeIsRejected(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("没有成绩时清空应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "没有成绩") {
|
||||
t.Fatalf("400 应说清没有成绩可清,得到 %+v", env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 权限与录成绩同档:访客 403,recorder 放行。
|
||||
func TestClearNeedsRecorder(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "clear-perm",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
)
|
||||
guest, _ := env.client()
|
||||
status, _ := env.do(guest, http.MethodPost, "/api/results/clear", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
|
||||
}, false)
|
||||
if status != http.StatusForbidden && status != http.StatusUnauthorized {
|
||||
t.Fatalf("访客清空应被挡下(401/403),得到 %d", status)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
|
||||
}
|
||||
|
||||
// 清空整个组别时,连带清掉「按单位录入的团体名次」:否则那一批分没有成绩行,
|
||||
// 重算会原样再写一遍——现场就变成「清空了榜上却还有分」。
|
||||
func TestClearGroupAlsoClearsTeamManualRanks(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 换成「团体 + 手工名次」通道:团体名次走 channel_manual_ranks。
|
||||
if _, err := env.ev.Exec(
|
||||
`DELETE FROM event_score_channels WHERE event_id = ?`, ids.event); err != nil {
|
||||
t.Fatalf("清通道: %v", err)
|
||||
}
|
||||
var profileID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil {
|
||||
t.Fatalf("读团体模板: %v", err)
|
||||
}
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'manual', ?, 0)`, ids.event, profileID)
|
||||
|
||||
// 录两个单位的名次 → 发布 → 榜上每个单位各得名次分。
|
||||
if status, e := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
|
||||
map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1}, {"teamId": ids.teamB, "rank": 2},
|
||||
}}, true); status != http.StatusOK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
// 前置:纯团体名次项目,报名项上没有成绩,分全挂在单位上(15 + 10)。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("前置:A 队应 15 分,得到 %v", got)
|
||||
}
|
||||
|
||||
body := clearResults(t, env, client, map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
|
||||
}, http.StatusOK)
|
||||
if body["teamRanks"] != float64(2) {
|
||||
t.Fatalf("应清掉 2 条团体名次,得到 %v", body["teamRanks"])
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, 0, ids.event)
|
||||
// 榜上的分必须真的归零——这正是「清空」与「撤销发布」现场差别最大的地方。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("清空后 A 队应 0 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("清空后 B 队应 0 分,得到 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
|
||||
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
|
||||
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
|
||||
// 清空当前组 / 批次成绩:危险操作(连成绩内容一起清),要勾选确认 + 写审计。
|
||||
mux.HandleFunc("POST /api/results/clear", s.write(auth.RoleRecorder, s.clearResultsHandler))
|
||||
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
|
||||
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
|
||||
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
|
||||
|
||||
Reference in new issue
Block a user