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
+62
-2
@@ -139,6 +139,33 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
||||
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。
|
||||
*
|
||||
* 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点:
|
||||
* 删掉一个人之后,哪些小项的榜单被重算了。
|
||||
*/
|
||||
export interface AthletePurgeResult {
|
||||
deletedEntries: number
|
||||
deletedResults: number
|
||||
deletedLedger: number
|
||||
deletedBreaks: number
|
||||
/** 因成绩删除而重算了积分与名次的小项 ID。 */
|
||||
recomputedEvents: number[]
|
||||
}
|
||||
|
||||
export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
|
||||
/**
|
||||
* 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。
|
||||
*
|
||||
* 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除:
|
||||
* 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水,
|
||||
* 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。
|
||||
* 人员已被别人删掉时回 404。
|
||||
*/
|
||||
purge(id: number): Promise<AthletePurgeResult>
|
||||
}
|
||||
|
||||
export interface EntriesApi {
|
||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
@@ -252,6 +279,39 @@ function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读删除统计。
|
||||
*
|
||||
* 统计缺失时按 0 / 空数组处理:删除在服务端已经提交、不可回滚,这时提示里冒出 undefined
|
||||
* 或直接报「删除失败」都是在骗操作员。老服务端不认 ?purge=1、data 为 null 时也会走到这里。
|
||||
*/
|
||||
function readPurgeResult(value: unknown): AthletePurgeResult {
|
||||
const source = isRecord(value) ? value : {}
|
||||
const count = (key: string): number => {
|
||||
const raw = source[key]
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
||||
}
|
||||
const events = source['recomputedEvents']
|
||||
return {
|
||||
deletedEntries: count('deletedEntries'),
|
||||
deletedResults: count('deletedResults'),
|
||||
deletedLedger: count('deletedLedger'),
|
||||
deletedBreaks: count('deletedBreaks'),
|
||||
recomputedEvents: Array.isArray(events)
|
||||
? events.filter((item): item is number => typeof item === 'number')
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createAthletesApi(http: Http): AthletesApi {
|
||||
return {
|
||||
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
|
||||
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
|
||||
purge: async (id) =>
|
||||
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
|
||||
}
|
||||
}
|
||||
|
||||
function createEventsApi(http: Http): EventsApi {
|
||||
return {
|
||||
...createCrudApi<Event, EventInput>(http, '/events'),
|
||||
@@ -387,7 +447,7 @@ export class ApiClient {
|
||||
readonly sports: CrudApi<Sport, SportInput>
|
||||
readonly events: EventsApi
|
||||
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
||||
readonly athletes: CrudApi<Athlete, AthleteInput>
|
||||
readonly athletes: AthletesApi
|
||||
readonly entries: EntriesApi
|
||||
readonly results: ResultsApi
|
||||
readonly standings: StandingsApi
|
||||
@@ -452,7 +512,7 @@ export class ApiClient {
|
||||
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
||||
this.events = createEventsApi(http)
|
||||
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
||||
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
|
||||
this.athletes = createAthletesApi(http)
|
||||
|
||||
this.entries = {
|
||||
importFile: (file) => {
|
||||
|
||||
Reference in new issue
Block a user