feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范

## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。

管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。

## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。

## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。

## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。

测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
This commit is contained in:
AskaEth committed 2026-09-10 22:37:25 +08:00
1 parent 22ab49e73a
commit 9d4ee0946c
22 files changed
+1264 -54

No files matched your search

@@ -0,0 +1,185 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 运动员「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。
*
* 服务端的原文(例如 404「运动员不存在」)一旦被换成「操作失败」现场就只能靠猜;
* 而 404 又是「人员已不在」的真实回答,绝不能降级成示例数据假装删掉了。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.purgeAthlete(7)
assert.equal(lastRequest.url, '/api/athletes/7?purge=1')
assert.equal(lastRequest.method, 'DELETE')
})
// 字段名与服务端 athletePurge 一致;页面提示语要靠它说清「删了什么、重算了哪些小项」。
test('成功响应带统计时,客户端拿到删除规模与重算小项', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { deletedEntries: 1, deletedResults: 1, deletedLedger: 2, deletedBreaks: 0, recomputedEvents: [1] },
}),
)
const result = await adminApi.purgeAthlete(7)
assert.deepEqual(result, {
deletedEntries: 1,
deletedResults: 1,
deletedLedger: 2,
deletedBreaks: 0,
recomputedEvents: [1],
})
})
// 删除在服务端已经提交、不可回滚:统计缺失时也只能按 0 报,不能冒出 undefined 或报成失败。
test('服务端没带统计时按 0 / 空数组兜底', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
const result = await adminApi.purgeAthlete(7)
assert.deepEqual(result, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
})
// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,删不掉还看不出原因。
test('彻底删除带上 CSRF 双提交 Cookie 的请求头', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
const globalWithDocument = globalThis as { document?: unknown }
globalWithDocument.document = { cookie: 'csrf_token=token-123' }
try {
await adminApi.purgeAthlete(1)
} finally {
delete globalWithDocument.document
}
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123')
})
test('失败时不吞错误:服务端那句原文原样带出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '服务端异常' } }, 500),
)
await assert.rejects(
() => adminApi.purgeAthlete(7),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 500)
assert.equal(error.code, 'INTERNAL')
assert.equal(error.message, '服务端异常')
return true
},
)
})
// 页面调用的是 baseDataApi 这一层:权限 / 服务端错误都是真实回答,不能降级成示例数据假装删掉了。
test('基础数据层把服务端错误原样抛给页面,不降级成示例数据', async () => {
baseDataFallback.value = false
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要管理员权限' } }, 403),
)
await assert.rejects(() => baseDataApi.athletes.purge(7), (error: Error) =>
error.message.includes('需要管理员权限'),
)
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
})
// 404 是「人员已经被别人删掉」这种真实回答,不是「接口未实现」:
// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。
test('purge 收到 404 时不降级,错误原样抛给页面', async () => {
baseDataFallback.value = false
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404),
)
await assert.rejects(
() => baseDataApi.athletes.purge(7),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '运动员不存在')
return true
},
)
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
})
// 对照组:同组的软删除 remove 行为不变,404 仍按「接口未实现」降级到示例数据。
// 示例数据里 id=1001 存在,能删掉就说明确实走了 mock 分支,而不是把 404 抛了出来。
test('remove 的 404 仍降级到示例数据(既有行为未受影响)', async () => {
baseDataFallback.value = false
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404),
)
await baseDataApi.athletes.remove(1001)
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
baseDataFallback.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.athletes.purge(1002)
assert.deepEqual(result, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
assert.equal(baseDataFallback.value, true)
})