Commit Graph
22 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 1b703d13ae style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
Deploy / deploy (push) Canceled after 0s
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:12:18 +08:00
xuyueandClaude Opus 5.5 29c31640b5 refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:07 +08:00
xuyueandClaude Opus 5.5 195069450f style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:52:24 +08:00
xuyueandClaude Opus 5.5 646c57c748 refactor(题单): 难度、状态、分数、提示、总分、百分比和 problemset_submission 表不再读写,先补默认值、下一步再删
Deploy / deploy (push) Canceled after 0s
机房和服务器共用一个库、不一定同时升级,所以分两步退役:0027 只给还非空的几列补默认值,
新代码插行不用再填、没升级的那边照旧能跑;两个站点都换上新代码后,再单独一条迁移删列删表
(要手工放行破坏性迁移,语句写在 0027 的注释里)。

进度算法随之去掉分数和百分比,判题记进度不再往 problemset_submission 写一行(从来没人读)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:08:15 +08:00
xuyueandClaude Opus 5.5 8c53e28dcb feat(题单): 「截止时间」改成「布置期」,布置期内没加入的人也藏旧代码;老师能看全班情况,后台一次加好几道题
- 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题,
  学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题
- 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026)
- /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有
- 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks
- 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:01:39 +08:00
xuyueandClaude Opus 5.5 af2ca31de5 fix(学生管理员): 260 班看板 23 人、数据统计只有 22 人,学生管理员还能看同学的作业代码——学生管理员一律按学生算
Deploy / deploy (push) Canceled after 0s
- 统计、题目页各班表、流程图统计的班级名单原来只认普通用户,改成和看板、排行榜一样的
  STUDENT_ROLES,学生管理员进名单、进分母
- 看别人的提交代码和流程图只给老师、超管和出题人;题单防抄对学生管理员也生效。
  原来「比赛进行中学生管理员不能看」那条特例随之去掉
- 题单的「用户进度」原来只给题单创建者,其余老师点开报「题单不存在」;改成所有老师都能看,
  和看板、统计对所有班开放一致。后台能改能删的接口仍只给创建者

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:11 +08:00
xuyue d25b5dbe6d update
Deploy / deploy (push) Canceled after 0s
2026-09-27 19:36:28 +08:00
xuyueandClaude Opus 5.5 23844ae301 fix(班级筛选): 输入 ks251 会把 2511 班的学生也算进来
Deploy / deploy (push) Canceled after 0s
班级号 3~4 位并存,按用户名前缀/包含匹配时 ks251 会捞到 ks2510~ks2519。
新增 classPrefixCondition:ks + 3 位以上数字按具体班级匹配(后面不能再跟数字),
1~2 位仍是年级前缀。后台首页的用户排名、随机抽签,以及题单的学生进度筛选
(只填数字时补上 ks)都改用它。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:34:28 +08:00
xuyueandClaude Opus 5.5 9412ba3cf6 refactor(契约): 展示题号统一叫 problemDisplayId,题单 id 统一叫 problemSetId
Deploy / deploy (push) Canceled after 0s
前后端同时改,部署前开着的旧页面要刷新一次(题号列会是空的、提交筛选会失效)。

- 挂在非题目对象上的展示题号,原来叫 problem(字符串)或 displayId,统一成
  problemDisplayId:提交列表、流程图列表、统计的 lastFailure / items /
  今日最热题、站内信里的提交、后台题单的题目列表。别处的 problem 都是对象
  或数字 id,这几处是字符串,最容易看错
- 列表筛选的 query 参数 ?problemId= 实际传的是展示题号,改成
  ?problemDisplayId=(提交列表、流程图列表、三个统计接口)
- 契约里 problemsetId / problemSetId、problemset / problemSet 两种拼法并存,
  统一成 problemSetId / problemSet,和 ProblemSet* 类型名、problem-sets
  路径、前端路由参数一致。drizzle 的列键跟表名走,没动
- aiDetail.user(用户名字符串,而契约里别的 user 都是对象)→ username。
  ai_analysis.data 里存的历史快照仍是 user,没有代码读回它
- 后台题单题目表的列是按字符串键 'displayId' 取的,类型检查抓不到,一并改

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:08:31 +08:00
xuyueandClaude Opus 5.5 5ae830a7f9 refactor: 一批名不副实的命名改掉(只动一端,不碰契约)
后端:
- getUserByToken → authenticateToken:它会删会话、续 TTL、记在线,不是纯读
- todayStart → dayStart(instant):本来就接受任意时刻,不只是「今天」
- objectValue → asRecord,删掉 4 份一模一样的私有拷贝和 `as toObject` 别名
- 报错文案 "does not exists" / "Already be a public problem" 改通顺

前端:
- utils/types 里 Contest / User / Problem 三个别名删掉:Contest 原来指后台
  比赛、契约里同名的却是学生侧。现在 Contest = 学生侧、AdminContest = 后台,
  后台用户表单叫 AdminUserForm,题目详情直接用 ProblemDetail
- SubmissionInfo → ContestSubmissionInfo,免得和 submission.info 撞名
- utoa / atou → compressToBase64 / decompressFromBase64(实际是 zlib + base64),
  encode / decode → base64EncodeUtf8 / base64DecodeUtf8
- filterResult → toProblemRow、toProblemListItem → toAdminProblemRow,
  行字段 submission / rate → submissionCount / acRate
- 组件:Modal → AddFromLibraryModal、Form → EditorToolbar、
  ContestEditor → BasicEditor(题单也在用它)、rank 的 Index → RankMedal、
  Name → UserNameCell、Password → PasswordCell、ContestType → ContestAccessTag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:08:21 +08:00
xuyueandClaude Opus 5.5 f2ca34fd4b refactor(api): 请求体解析收成 readJson / parseBody
54 处 handler 都手写同一段:
  const parsed = X.safeParse(await c.req.json().catch(() => null))
  if (!parsed.success) return failure(c, 400, "invalid-request", ...)
漏写 .catch 就是一个畸形请求打出 500。

- http.ts 加 readJson(读 + 校验)和 parseBody(再加上 400)。parseBody 不给
  message 时回 zod 的第一条 issue —— 后台表单原来就是这么写的,fallback 文案
  「Invalid payload」/「参数错误」只在 issues 为空时才用得上,实际走不到
- 51 处换成 parseBody;另 3 处失败时带别的错误码或和路径参数一起判,换成 readJson
- 错误码、状态码、文案、判断顺序都不变

实跑核过:非 JSON 请求体回原来的固定文案,公告标题为空回 zod issue。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:04:19 +08:00
xuyueandClaude Opus 5 ed56a209ea chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00
xuyueandClaude Opus 5 eda1eb7eee chore(题单): 删掉客户端自报进度的 PUT /problem-set-progress
Deploy / deploy (push) Has been cancelled
这个端点是「AC 之后前端回调一下,把进度写进题单」那套设计的残留,早就被服务端记账
取代了 —— SubmitCode.vue 里留着当时的说明:客户端那条路只认路由参数里的那一个题单
(从普通题库入口做出同一道题不计进度),网络一抖、页面提前关掉进度就静默丢失;
现在判完之后由 judge/run.ts → services/problemset.ts 的 recordSolvedProblem 记账,
而且记进所有已加入且包含这道题的题单。

上一个提交删掉前端最后一个调用方 updateProblemSetProgress 之后,它就彻底没人打了。
留着的代价不只是死代码:那是一条**学生可以自己写进度**的写入口。

删之前逐个核过:
- 前端零引用(唯一的 wrapper 已在上个提交删掉);
- recomputeProgress 还有 POST 那条在用,保留;computeProgress / eligibleForBadge /
  updateAchievementsForProblemSet / publishAchievementNotification 在别处都有调用方;
- 它往 problemset_submission 写的那一笔,services/problemset.ts:260 做的是一模一样的
  去重后插入,不会因此少写。

顺带清掉因此变成孤儿的四个 import 和契约里的 updateProblemSetProgressRequestSchema
与 UpdateProblemSetProgressRequest。

## 验证

起服务实跑:
- PUT /api/problem-set-progress 现在 404;
- 保留的 POST(加入题单)仍然 201,progress 行照常由 recomputeProgress 建出来;
- 服务端那条替代路径端到端跑通:新建题单 → 加入题目 1004 → 学生加入 → 交一发 AC,
  判完后 problemset_progress 自动变成 completed=1/total=1/100%/得分 10,
  problemset_submission 也落了一行 —— 全程没有任何客户端回调。

tsc、check:routes、vue-tsc、单二进制编译均通过;测试题单已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:42:17 -06:00
xuyueandClaude Opus 5 ab47e71d6f refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
  前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
  建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
  (error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
  后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
  problemDisplayId 收到 undefined,改动后同样两个响应均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00
xuyueandClaude Opus 5 e5a6f2d1e7 fix(题单): 归属校验、进度分母、截止时间可见性等六处零碎
Deploy / deploy (push) Has been cancelled
## user-progress 缺归属校验

学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验,
任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台
loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。

顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」,
与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null
(SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条
GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。

## 头部进度的分母

分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 ——
前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张
卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把
「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。

## 截止时间

end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个
带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。

## 两个必然筛空的筛选器

学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」
「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着,
哪天真的用起这两个字段再把 select 加回来。

## 题目移出题单时的提交记录

旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission,
OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:01:58 -06:00
xuyueandClaude Opus 5 74b97c610f fix(题单): 「选做」终于作数了,分母只算必做题
isRequired 一直只是卡片上的一行字:卡片写着「(选做)」,进度分母和 all_problems
奖章却照样要求做完。结果是学生按提示跳过选做题,进度条卡在 100% 以下、全通奖章也
拿不到 —— 快照里 22 个人做完了全部必做题却显示未完成(题单 5 三人、6 十人、8 两人、
11 七人)。旧栈的 update_progress 同样不区分,是一路继承下来的。

改成:分母只算必做题,选做题做了仍然计分(totalScore 把它算进去),只是不卡完成。
一道必做都没标的题单退回「全部都算必做」—— 那种题单多半是没用这个字段,而不是真的
整单选做,不兜住的话它永远完不成。

## problem_count 奖章不能跟着改

老师当初是按题单的**总题数**设阈值的:题单 5 的「一职欧拉」要 8 题,而它的必做只有
7 道。要是 problem_count 也改用只数必做的 completedProblemsCount,这枚奖章一夜之间
不可得,76 个已经拿到的人会被 recalculateBadge 收回。所以它数的是「做出的题目总数
(含选做)」,从 progress_detail 的键数来。score 类同理不受影响。

预演证实了这道闸门有效:22 人拿到完成状态,奖章补发 56 条、**收回 0 条**。

## 顺带:第三份手抄的达标逻辑

PUT /problem-set-progress 里还藏着一份 inline 的奖章判定,和 services 里那份、
补发脚本里那份是三份各写各的 —— 这次改 problem_count 的语义,漏掉任何一份都会让
学生提交时发的奖章和后台重算的结果对不上。三处统一到 eligibleForBadge。

## 工具改名并扩到进度

backfill-badges → backfill-problemsets。语义变更之后,已有的进度行要跑一遍才会按新
规则重算,否则那 22 个人得等到老师下次动题单才生效。两笔账本来也是同一笔:进度一变,
奖章达标面就跟着变,所以落库走 resyncProgress(它重算进度后会顺手重算该题单全部奖章),
预演里的奖章差异也是照着订正后的进度算的,保证预演和 --apply 的结果一致。

在生产快照上实跑:

  合计:进度 494 条要重算(完成 +22 / -0),奖章补发 56 条、收回 0 条
  已订正 10 个题单 → 复核通过:题单数据与规则一致
  user_badge 1180 → 1236,已完成 621 → 643
  「未完成但有完成时间」仍是 4 条(d7a6414 那条语义保住了)
  重复跑幂等:进度 0 条、奖章 0 条

抽查题单 6(9 必做 + 1 选做):只做必做的显示 9/9 100% 已完成、80 分;连选做一起做的
同样 9/9 已完成,但 90 分。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:55:36 -06:00
xuyueandClaude Opus 5 d9e6a2a3f0 perf(题单): 学生端题目列表少两次 join 一次查询,顺带把并列的 order 排稳
## 排序

学生端 /problem-sets/:id/problems 只按 order 排,没有 tiebreaker,而卡片是按数组
下标编号的(#1 #2 #3)。order 并列时 Postgres 不保证次序,题单 8(3 道题 order 都是
10)、题单 11(2 道并列 4)、题单 14(3 道并列 0)实际就有并列,于是「第 3 题」指
哪道题每次刷新都可能变。后台那条列表一直是 order + id 排的,两边本来就不一致。

补上 asc(id)。教师进度视图里那份题目清单(同一路由文件 :398)有同样的问题,一并补。

实跑:四道题、三道 order 并列,连打 5 次次序完全一致。

## 载荷

这个接口原来复用 problemListItemSchema,为此要多 join user + user_profile 凑
createdBy、再多查一次标签表凑 tags —— 而题单卡片只渲染题号、标题、难度、分数和
完成标记。tags / submissionNumber / acceptedNumber / createdBy / contestId /
allowFlowchart / showFlowchart / hasAstRules 一个都不用,myStatus 甚至写死是 null。

而且 select 的是 schema.problem 整行,题面、样例、答案、ast_rules、flowchart_data、
sql_display 全都白拉回来一遍。

改成 problemSetProblemItemSchema(id / _id / title / difficulty 四个字段),查询相应
收窄:两次 join 去掉,标签那次查询整条去掉,行查询只取四列。每次请求从 4 条查询降到
3 条,其中最大的那条不再拖整行题面。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:47:57 -06:00
xuyueandClaude Opus 5 f9354b0df1 fix(题单): 进度重算漏了分数和完成状态,奖章没人补发
Deploy / deploy (push) Has been cancelled
进度算法在学生路径和后台路径各写了一遍,于是各自漂了一段。后台那份
(resyncProgress)名叫「把所有参与者的进度重算一遍」,实际只更新分母和百分比:

- 不碰 total_score。改题目分值时专门调了它,函数体里却没这个字段,score 类奖章
  因此按陈旧分数判定。
- 不碰 is_completed。加一道题之后分母变大、百分比掉下来,人还标着「已完成」。
- 不清理 progress_detail。删掉一道题之后 least(completed, total) 只保证不超过分母,
  会把没做的题算成做了 —— 3 题的题单只做出 C,删掉 C 就变成 1/2 = 50%。
- 不重算奖章。题目集一变 all_problems 的达标面就变了,没有任何地方补发。

最后一条攒下了实打实的欠账:8-07 的快照里 53 条应发未发,涉及 30 名学生,误发 0 条。
其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度 —— 进度补了,
奖章没人回头判。

两边不再分叉的唯一办法是只留一处算法,所以抽出 services/problemset.ts:
computeProgress 是纯函数,学生做出一题和后台改题目都只是调用者;
eligibleForBadge / recalculateBadge / resyncProgress 一并搬过来,补发脚本才能复用
同一套判定。批量写回仍是一条 UPDATE ... FROM (VALUES ...),没退回逐行往返。

顺带修掉空题单的坑:isCompleted 加了 total > 0 前提。原来 0 === 0 也成立,老师先建
题单、学生先加入、题目后加,加入那一刻就写下 complete_time 并计进「完成题单数」成就,
而且后面补上题目也不会自愈。

一处行为变化:退回未完成时 complete_time 会清空。后台那份原来保留旧时间,学生那份
一直是清空的,统一成后者 —— 否则同一行会出现「未完成 + 有完成时间」,破坏现有数据里
「complete_time 非空 ⟺ is_completed」这条不变量。代价是加题再删题会把历史完成时间
洗成「现在」。

scripts/backfill-problemset-badges.ts 补历史欠账,默认只读预演,--apply 才落库。
只要存在误发就拒绝执行并打出名单,要连收回一起做得显式加 --allow-revoke ——
recalculateBadge 的删除是真删,user_badge 的 earned_time 没有别处备份。

在生产快照上实跑:补发 1180 → 1233 条,复核全部一致,历史 earned_time 未被刷新;
resyncProgress 对题单 9(7 题 / 117 人 / 100 人完成)加题后正确退回 0 人完成并收回
100 枚奖章,删题后完整恢复;改题目分值 10→20 使总分和 7460 → 8590,正好 +113×10。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:23:38 -06:00
xuyueandClaude Opus 5 a75c70c82d perf(后端): 干掉 14 处 N+1 查询
Deploy / deploy (push) Has been cancelled
列表接口按行发查询是从阶段 3 一路带过来的写法:`Promise.all(rows.map(...))`
看着是并发的,但每行都往库里打一次,行数一多就是几百上千次往返。同样的模式
也散在几个后台批处理和写入路径里。全部改成「先收集 id,一条 inArray/group by
查回来建 Map」——`routes/problem.ts` 的 getProblemTags 早就是这么写的,这次
只是把剩下的地方对齐。

用户可见的列表:

- `GET /problem-sets` 每行 5 条(题目数/我的进度/奖章/已获奖章/创建者),
  limit 上限 250 就是 1250 次往返。改成固定 5 条,与行数无关。
- `GET /contests` 每场比赛一条 creator 查询。后台的比赛列表本来就是 join
  出来的,只有这条公开列表漏了。
- `GET /admin/problems`、`GET /admin/contests/:id/problems` 每题一条标签查询。
- `GET /admin/problem-sets` 每行 3 条;`.../badges` 每个奖章一条 count。

后台批处理:

- `refreshContestJoinedForAll` 原来是每个用户 1 条 count 加一个独立事务里的
  insert/select for update/update。改成一条 group by 出全部用户的场次,再分批
  upsert,`metrics || excluded.metrics` 是 jsonb 浅合并,只覆盖 contest_joined
  一个键,其余指标原样保留 —— 合并在一条语句里完成,for update 那把锁不再需要。
- `rescanAchievement` 补发循环、`unlockAchievements`:命中的一次插完,
  onConflictDoNothing 的 returning 就是真新解锁的那批,unlockCount 改成一次 +N。
- `resyncProgress` 逐行 UPDATE 改成一条,completed 用 least() 夹住。

写入路径:

- 标签解析抽出 normalizeTagNames + findTagsByName(一条 lower(name) IN),
  新建题、改题、批量打标签三条路共用。
- 克隆比赛:题面一条 INSERT、标签一条 SELECT 加一条 INSERT。新旧题的对应
  关系靠 _id 认,不依赖 returning 的行序。
- `POST /admin/website` 8 个键一条多行 upsert。
- 题单奖章判定一次插完。

`/ai/duration`:原来每个时间桶两条查询、桶之间还串行,一年 12 个桶 24 次往返。
改成先算桶、再一条查询把整段区间拉回来在内存里分桶。时间戳用
`extract(epoch) * 1000` 取毫秒回来比,别指望 Date.parse 认 pg 那个
`2026-08-12 00:00:00+00` 格式。**相邻桶首尾相接、两端闭区间**(落在边界上的
提交两个桶都算)这条旧语义是照搬的,不要顺手改成半开区间。

验证:本机起 dev 栈,造了覆盖各分支的种子数据(创建者重复的题单、零题目/零
奖章的题单、completed > total 的脏进度、除不尽的百分比、大小写混写的已有标签、
带/不带标签的比赛题、正好落在分桶边界上的提交),旧代码跑一遍、新代码跑一遍:

- 51 个接口响应逐字节一致
- 12 张表的快照逐行一致(唯一差别是 progress_detail 里的 submit_time 墙钟值)
- 打开 log_statement=all 数过条数,例如 `GET /admin/problems` 20 道题
  24 → 5,`GET /problem-sets` 28 → 8,`/ai/duration` years:1 28 → 5,
  202 个用户的成就补发 1828 → 614

一处可观察的行为变化:补发现在整批共用一个 unlockTime,原来是每人一个
new Date()。rescanAchievement 上方的注释本来就写着补发会给几百人盖同一个
时间戳、前端据此只显示「已获得」不显示日期,所以这个方向是对的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:43:57 -06:00
xuyueandClaude Opus 5 95625d6711 refactor: 另外两处守卫也收到注册行上
走查题单进度时撞到 `/problem-sets/:id/user-progress` 对学生 403 —— 那是**正确**的
(它是给教师看全班进度的),但守卫又写在 handler 体内。评审的 M3 只列了 3 处,
按同一模式扫全仓,还有 2 处:

  content.ts     POST /messages                       isSuperAdmin
  problemset.ts  GET  /problem-sets/:id/user-progress  isTeacherOrAbove

改用 requireSuperAdmin / requireTeacher,理由同 M3:守卫要能从注册行上看出来,
不然下一个人加同类端点容易漏掉那个 if。

实测档位没变:学生两条都 403,教师看全班进度 200。
路由遮蔽检查仍然干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 06:28:44 -06:00
xuyueandClaude Opus 5 b4b61af6b0 fix(阶段3): 匿名不可读用户档案,真名改为默认不下发
F1:GET /profiles/:username 只挂了 optionalAuth、handler 内无登录判断,
匿名可读 email、adminType、className、lastLogin。用户名又能经 /rankings/users
公开枚举,等于可以无 cookie 批量收集全校学生的邮箱与最后登录时间。
handler 开头补上未登录即返回空,对齐旧后端 account/views/oj.py 的
UserProfileAPI.get 首行 `if not user.is_authenticated: return self.success()`。

F2:旧后端把「是否下发真名」做成 UsernameSerializer(need_real_name=False)
的默认关闭开关,全仓 11 处调用只有比赛榜单一处显式打开;新后端没搬这一层,
真名随用户对象无条件下发,13 个下发点里 8 个匿名可达。

这里补回同一层:helpers.ts 新增 sampleUser(),realName 默认不下发,
需要的地方显式传 { includeRealName: true }。12 个下发点改为走这个函数,
只有比赛榜单一处打开(对齐 contest/serializers.py:84 的 is_contest_admin)。
没有逐处删字段 —— 那样下次新增端点还会重犯。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:59:05 -06:00
xuyueandClaude Opus 5 8c00cdc947 feat(阶段3): oj 侧端点铺开(基线提交,未经评审)
由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线,
后续修复单独成 commit,便于区分与回退。

覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/
content/contest/flowchart/problemset/site)与对应 Zod 契约。

已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据;
judge 与 flowchart worker 均 ready。
未核验:权限边界与数据泄露,评审进行中。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:25:36 -06:00