fix(后台用户): 删账号前拦住还有提交的人,改名回填按 user_id

两处都是「提交与用户的归属」在写路径上没维护住。

删账号:处理器那段注释早就点到了 submission.user_id 没有外键、全连坐会留下孤儿行,
但只把它当成「不改成 CASCADE」的理由,没有加对应的检查。结果是报错文案里写着
「该用户还有提交、题目等历史数据」,而提交恰恰是唯一拦不住的那一类 —— 只交过题、
没拿过成就没进过题单没参加过比赛的学生照样删得掉。

生产快照实测复现:删 id=1454(李若菡,13 条提交)返回 200 deleted:1,用户没了、
13 条提交留在库里,孤儿总数从 935 涨到 948。那 935 条就是这么攒出来的(28 个已删
账号)。线上今天还有 5 个这样能删的学生。

补一次提交查询把它拦下来,和 delete 放同一个事务里免得中间正好交了一发。文案一个
字没改 —— 它本来就是对的,缺的是兑现它的代码。混批删除整批回滚,干净的那个也留着。

存量 935 条不动:四条读路径现在都用 leftJoin 兜住了,列表显示冻结的名字、不出死链,
统计按 user_id 聚合他们本来就不在花名册里。加外键得先清历史数据,收益只有「以后
不再产生」,而那一半这次已经解决了。

改名回填:条件从「等于旧用户名」改成按 user_id。前者只改得动当前正好还等于旧名的
行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 库里 726 条挂着旧名字的
提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。实测拿 user 2039 验过:
user 表叫 ks248吴紫妍、13 条提交挂着 ks24数媒1班ks吴紫妍,老写法一行都匹配不上,
改成按 user_id 之后一次拉平。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
This commit is contained in:
2026-09-08 18:29:19 -06:00
parent a89eed7bdd
commit 64ad6139f5

View File

@@ -233,10 +233,20 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id)) await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
// submission.username 是冗余列(判题历史按用户名查),改名后必须一起改,否则历史提交查不到 /**
* submission.username 是冗余列,改名后跟着改。
*
* 条件按 **user_id** 而不是「等于旧用户名」:后者只改得动「当前正好还等于旧名」
* 的行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 生产库里 726 条
* 挂着旧名字的提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。
* 按 user_id 写是幂等的,顺带把这个人的历史行一次性拉平。
*
* 读路径本身已经不依赖这一列了(列表和统计都从 user 表取当前名字),
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
*/
if (existing.user.username !== username) { if (existing.user.username !== username) {
await tx.update(schema.submission).set({ username }) await tx.update(schema.submission).set({ username })
.where(eq(schema.submission.username, existing.user.username)) .where(eq(schema.submission.userId, id))
} }
await tx.update(schema.userProfile).set({ realName: data.realName }) await tx.update(schema.userProfile).set({ realName: data.realName })
.where(eq(schema.userProfile.userId, id)) .where(eq(schema.userProfile.userId, id))
@@ -373,6 +383,9 @@ function isForeignKeyViolation(error: unknown) {
return false return false
} }
/** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */
class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => { adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required") if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
@@ -390,13 +403,33 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
// 别顺手把这里也改成全 CASCADEsubmission.user_id 压根没有外键Django 那边就是个 // 别顺手把这里也改成全 CASCADEsubmission.user_id 压根没有外键Django 那边就是个
// 裸 IntegerField全连坐的结果是成就没了、提交却留成孤儿行一半删一半留。 // 裸 IntegerField全连坐的结果是成就没了、提交却留成孤儿行一半删一半留。
try { try {
const deleted = await db.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids)) const deleted = await db.transaction(async (tx) => {
.returning({ id: schema.user.id }) /**
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** ——
* `submission.user_id` 没有外键Django 那边就是个裸 IntegerField上面已经
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 ——
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测
* 28 个已删账号留下 935 条这样的提交。
*
* 补一次查询把它拦下来,口径和外键那批一致:有历史数据就该禁用,不该删。
* 和 delete 放同一个事务里,免得中间正好交了一发。
*/
const [withSubmission] = await tx
.select({ userId: schema.submission.userId })
.from(schema.submission)
.where(inArray(schema.submission.userId, parsed.data.ids))
.limit(1)
if (withSubmission) throw new UserHasSubmissionsError()
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
})
return success(c, { deleted: deleted.length }) return success(c, { deleted: deleted.length })
} catch (error) { } catch (error) {
// 只有外键冲突23503才是「这人还有历史数据」。以前这里是裸 catch // 只有外键冲突23503和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
// 连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见 // catch连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (!isForeignKeyViolation(error)) throw error if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号") return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
} }
}) })