Files
OJ2/apps/api/src/routes/site.ts
yuetsh ed56a209ea
Some checks failed
Deploy / deploy (push) Has been cancelled
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
原来只有 `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

117 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { resolve } from "node:path"
import { onlineCount } from "../auth/presence"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { getWebsiteOptions } from "../services/options"
import { stripClassPrefix } from "./helpers"
export const siteRoutes = new Hono()
siteRoutes.get("/site", async (c) => {
const options = await getWebsiteOptions()
return success(c, {
websiteBaseUrl: options.website_base_url,
websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut,
websiteFooter: options.website_footer,
allowRegister: options.allow_register,
submissionListShowAll: options.submission_list_show_all,
classList: options.class_list,
enableMaxkb: options.enable_maxkb,
} satisfies WebsiteConfig)
})
/**
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/
siteRoutes.get("/site/online", async (c) => {
return success(c, { count: await onlineCount() } satisfies OnlineCount)
})
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
const fallbackQuotes = [
{
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
from: "Structure and Interpretation of Computer Programs",
},
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
]
// categories.json 里的 path 形如 "./sentences/a.json"12 个分类合计 20MB。
// 按分类懒加载并常驻缓存,但**只留前端用得上的两个字段** —— 原样缓存的话,
// 解析后的对象要占 60~100MB而 api 容器只有 512m。裁完全量也就几 MB。
let categoryPaths: string[] | null = null
const sentenceCache = new Map<string, Quote[]>()
async function loadSentences(path: string) {
const cached = sentenceCache.get(path)
if (cached) return cached
const raw = (await Bun.file(
resolve(config.hitokotoDirectory, path),
).json()) as { hitokoto?: unknown; from?: unknown }[]
const rows = (Array.isArray(raw) ? raw : [])
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
.map((it) => ({
hitokoto: it.hitokoto as string,
from: typeof it.from === "string" ? it.from : "佚名",
}))
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
sentenceCache.set(path, rows)
return rows
}
async function randomQuote() {
if (!categoryPaths) {
const categories = (await Bun.file(
resolve(config.hitokotoDirectory, "categories.json"),
).json()) as { path?: string }[]
const paths = categories
.map((it) => it.path)
.filter((it): it is string => typeof it === "string")
if (paths.length === 0) throw new Error("no hitokoto categories")
categoryPaths = paths
}
const path = categoryPaths[Math.floor(Math.random() * categoryPaths.length)]!
const sentences = await loadSentences(path)
return sentences[Math.floor(Math.random() * sentences.length)]!
}
siteRoutes.get("/quotes/random", async (c) => {
try {
return success(c, (await randomQuote()) satisfies Quote)
} catch {
const item =
fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, item satisfies Quote)
}
})
siteRoutes.get("/classes/:className/usernames", async (c) => {
const className = c.req.param("className").trim()
if (!/^\d{3,4}$/.test(className)) {
return failure(
c,
400,
"invalid-class",
"Class name must contain 3 or 4 digits",
)
}
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
// 用 stripClassPrefix 而不是 replacereplace 会把中间的匹配也删掉,前缀对不上时截出乱码
return success(
c,
rows.map(({ username }) => stripClassPrefix(username, className)),
)
})