Commit Graph
13 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 cccf7a37be feat(成就): 多了 9 个好拿的档和「挑战成功」一条线,比赛里做的题也算,这学期八成学生多拿到成就
- 补档:做对 20 道、第一道中等题、交满 30 次、5 天交过题、一次就对 5 道、错 3 次后做对、一天做对 5 道;
  新线「挑战成功」1 次 / 5 次
- 比赛里的提交也计入成就(比赛其实是单班测验,2612 班整班在比赛里做题,原来一个成就都没有);
  参加过几场比赛改成判题时就更新,以前只在后台重扫时刷新
- 奖杯收藏家从 15 个提到 20 个
- 0044 迁移:按新口径从提交记录重数指标,给达标的人补发(已经拿到的不收回)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 +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 55038cc78b fix(判题): 判题机卡住不再让全班一直「等待评分」,重判和同时交对也不再把做对题数算多
Deploy / deploy (push) Canceled after 0s
- 正式判题、样例重跑、运行时错误诊断都加了超时(默认 120 秒,时限放宽的题按比例放宽),
  超时记系统错误、提示是中文;回包和试运行一样边读边数,超过 16MB 就掐断,
  免得一个疯狂打印的错误程序把 worker 撑爆
- 做对题数、中等、困难、一次就对改成在锁里按定义现数:原来逐次 +1,两条挨着判完或重判
  一条做对的提交都会多加,库里 372 人多了 1–4 道。0035 把存量一次订正
- 重判不再给提交次数、凌晨/早起计数 +1,编译错误按前后差值改;「错了几次才对」只数这条之前的
- 已拿到的成就数只 jsonb_set 那一个键,不再整块写回冲掉并发判题刚写的指标

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:15:08 +08:00
xuyueandClaude Opus 5.5 85108df9d8 feat(成就): 下架三个拿不到的成就、理顺稀有度和图标;接口多给本班几人有和「本班」卡片
- 0032:下架六边形战士(判题机只剩 3 种语言)、坚持三天/一周(按天算基本拿不到);
  不撞南墙改白银、屡败屡战改黄金;百发百中换 💯、屡败屡战换 💪,不再和别的撞图标;
  代码行数只算做对的、空行不算,并按新口径重算所有人;已经拿到的一个不收回
- 奖杯收藏家只数上架的成就(判题结算、补发重算、0032 存量一起改)
- GET /achievements:每个成就带全站几人有、本班几人有;看自己时带「本班」(最近拿到、成就最多前五、自己第几);
  隐藏成就同一串拿到一档就整串亮出来;连续几周给「现在连着几周」而不是历史最长
- 删掉只有旧成就页在用的 /achievements/summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:26:37 +08:00
xuyueandClaude Opus 5.5 71670e609b feat(成就): 新增「最长连续新通过周数」指标,按课表的节奏算连续;修掉比赛成就重扫因孤儿提交失败
Deploy / deploy (push) Canceled after 0s
按天的连续成就对这里的学生基本拿不到:2025 秋最长连续交题天数的中位数是 1 天,
连续 7 天的只有 3 人。按周算,连续 2/4/8 周分别有约 73%/49%/8% 的学生做到过。

- 新指标 max_ac_week_streak:连续几个自然周(周一起、东八区)每周都至少新通过一道题
  (不含比赛)。判题结算时增量累计(比按天那段多一道判断:重判更早的提交不会把连续清零);
  后台建成就触发重扫时,先从提交记录全量补算(refreshAcWeekStreakForAll),同 contest_joined
- 老的「坚持三天」「坚持一周」不动:下架的成就连已获得的人也看不到
- 修:库里 28 个已不存在的用户留下了 935 条提交(其中 11 人有比赛提交),全量补算往
  user_stat 写时撞外键,整次失败——比赛类成就的重扫因此一直是失败的;两处都改为只算
  还在的用户。本地修好后重扫「初入赛场」补发了 3 人
- 本地验证:连续 2/4/8 周补发 1086/618/123 人,与独立 SQL 一致

线上要在后台手动建成就(指标选「最长连续新通过周数」),建的时候自动补发。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:27:25 +08:00
xuyue d25b5dbe6d update
Deploy / deploy (push) Canceled after 0s
2026-09-27 19:36:28 +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 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 3921c496cb fix(成就): 后台补发成就后重算已解锁数并接着判「奖杯收藏家」;recount 订正存量
rescanAchievement 只插 user_achievement、加 unlock_count,不重算
achievement_unlocked_count,也不做元成就的第二轮判定(旧 rescan_achievement
原样如此)。判题结算只在「这次有新解锁」时才重算,所以被补发的人计数会一直停在
旧值。2026-09-07 一次补发之后 269 人少算,其中 10 人实际够 15 个却没拿到
「奖杯收藏家」。

- 新增 refreshUnlockedCount:一条 SQL 按 user_achievement 重算,只 jsonb_set
  这一个键、只写值变了的行。rescanAchievement 补发非白金成就后调用它,再补发元成就。
- recount 同时核对已解锁数与元成就漏发,--apply 先改计数再补发,复核同一份口径。

用 09-14 生产备份实跑:recount 订正 269 人、补发 10 条,复核通过、重跑无差异,
其余指标 0 行被动;模拟调低阈值补发 1504 条,「奖杯收藏家」随之 65 → 95,
与预先算出的跨线人数一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:38 -06:00
xuyueandClaude Opus 5 4c0c38445c fix(时区): 日历口径收回东八区,读出的时刻统一成 ISO 并保留微秒
旧栈 Django 按 Asia/Shanghai 算日历,OJ2 重写时这个锚点丢了:容器和数据库会话
都是 UTC,于是「今日提交」在北京时间 0–8 点是空的,「凌晨/早起提交次数」整体偏
8 小时,热力图、AC 趋势年份、近两年活跃人数也各按进程时区切。

- 新增 apps/api/src/time.ts 作为唯一锚点(固定 +8 偏移,不依赖进程 TZ / tzdata),
  todayStart、成就小时/日期键、热力图、月份平移、年份夹逼全部改走它;
  SQL 里按日历切的一律显式 at time zone。
- db/index.ts:连接会话时区设为东八区(兜底);给 timestamptz(1184) 挂 parser,
  读出统一成 ISO 8601 UTC,撤掉为拿 PG 文本形状写的 ::text。parser 保留微秒 ——
  生产库 12.3 万条提交几乎全带微秒,截成毫秒会让翻页分界行和班级 AC 排名的
  <= min(create_time) 把自己排除(翻页每页丢一条、排名少 1)。
- 前端 parseTime/zonedParts/zonedYear 按 Asia/Shanghai 渲染,n-date-picker 做
  toPickerValue/fromPickerValue 平移,站内不再按浏览器时区取时间部件。
- Dockerfile 设 TZ=Asia/Shanghai 作为第二道兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:17 -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 7e1a782747 feat(阶段4): 用户管理 + 成就管理;补上 rescan 与 contest_joined
GET/POST/DELETE   admin/users            (DELETE 走 body 传 ids)
  GET/PUT           admin/users/:id
  POST              admin/users/:id/reset-password
  GET               admin/achievement-metrics
  GET/POST          admin/achievements
  GET/PUT/DELETE    admin/achievements/:id

顺带补了新后端缺失的两块(不补的话成就后台建出来的东西是坏的):

1. **rescanAchievement**:旧后端 `rescan_achievement` 的对应实现。判定平时只在判题
   结算时发生,后台新建成就或调低阈值不会自动补发,必须显式扫一遍。补发标记
   backfilled=true —— 前端据此只显示「已获得」不显示日期,否则一次补发会给几百人
   盖同一个时间戳,把「最近获得」板块冲垮。
   触发判据包含 metric 与 visible 的变化,不只看 operator/threshold:换维度、
   以及从下架改上架(草稿期已达标的人)这两种都会漏。
2. **contest_joined 指标整个漏掉了**。旧 METRIC_REGISTRY 有 18 个指标,新后端只算
   17 个,配在这个指标上的成就永远解锁不了。已补上计算,并把注册表抽成
   services/achievement-metrics.ts 作为单一事实源 —— 后台下拉框和参数校验都读它,
   避免「下拉框里选得到但没人算」这种组合。

用户管理的几处要点:

- className 解析位数不对**直接报错不猜**。猜错会把 class_name 存歪,而剥前缀显示
  姓名、班级下拉、统计页都依赖它准确。
- problem_permission 按 admin_type 归一(超管恒 All、普通用户恒 None),否则把超管
  降级成普通用户后他还留着 All。
- 改用户名要同步 submission.username 这个冗余列,否则历史提交查不到。
- openApi 已开着就不重置 appkey,否则每次保存用户都把对方的 key 换掉。
- **删除用户不复刻 Django 的应用层级联硬删**。用户是被引用最广的一张表,改成让
  数据库外键拦下来:撞外键说明还有历史数据,应当禁用而不是删除,返回 409 并说明。

实测:学生 403;导入 2 人 / 重复 409 / 班级号位数报错文案正确;改名+降权后
permission 归一为 None、重名 409;重置密码 6 位无 0;删自己 400;
成就指标 18 项、新建后补发 unlockCount=2、野指标与野稀有度均 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:27:36 -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