From 64ad6139f5062dcb2359d018157a6ab42aebd0c2 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 8 Sep 2026 18:29:19 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7):=20?= =?UTF-8?q?=E5=88=A0=E8=B4=A6=E5=8F=B7=E5=89=8D=E6=8B=A6=E4=BD=8F=E8=BF=98?= =?UTF-8?q?=E6=9C=89=E6=8F=90=E4=BA=A4=E7=9A=84=E4=BA=BA=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E5=9B=9E=E5=A1=AB=E6=8C=89=20user=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处都是「提交与用户的归属」在写路径上没维护住。 删账号:处理器那段注释早就点到了 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 Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd --- apps/api/src/routes/admin/account.ts | 47 +++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index ed9c63c..f42c1f1 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -233,10 +233,20 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { await db.transaction(async (tx) => { 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) { 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 }) .where(eq(schema.userProfile.userId, id)) @@ -373,6 +383,9 @@ function isForeignKeyViolation(error: unknown) { return false } +/** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */ +class UserHasSubmissionsError extends Error {} + adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => { const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required") @@ -390,13 +403,33 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => { // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 // 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。 try { - const deleted = await db.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids)) - .returning({ id: schema.user.id }) + const deleted = await db.transaction(async (tx) => { + /** + * 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** —— + * `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 }) } catch (error) { - // 只有外键冲突(23503)才是「这人还有历史数据」。以前这里是裸 catch, - // 连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见 - if (!isForeignKeyViolation(error)) throw error + // 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸 + // catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见 + if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号") } })