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:
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)
|
||||
})
|
||||
Reference in new issue
Block a user