import type { Quote, WebsiteConfig } from '@oj2/contract' import { asc, desc, eq } from 'drizzle-orm' import { Hono } from 'hono' import { resolve } from 'node:path' 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, { websiteName: options.website_name, allowRegister: options.allow_register, submissionListShowAll: options.submission_list_show_all, classList: options.class_list, } satisfies WebsiteConfig) }) // 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里) const fallbackQuotes = [ { hitokoto: '程序首先是写给人读的,其次才是让机器执行。', from: 'Structure and Interpretation of Computer Programs', fromWho: null, type: null, }, { hitokoto: '把大问题拆成足够小的问题,答案就会浮现。', from: '判题狗', fromWho: null, type: null, }, { hitokoto: '一次没通过,只是多得到了一条线索。', from: '判题狗', fromWho: null, type: null }, ] satisfies Quote[] // categories.json 里的 path 形如 "./sentences/a.json",12 个分类合计 20MB。 // 按分类懒加载并常驻缓存,但**只留前端用得上的几个字段**(句子、出处、谁说的、分类名)—— // 原样缓存的话,解析后的对象要占 60~100MB,而 api 容器只有 512m。裁完全量也就几 MB。 let categories: { path: string; name: string | null }[] | null = null const sentenceCache = new Map() async function loadSentences(path: string, type: string | null) { const cached = sentenceCache.get(path) if (cached) return cached const raw = (await Bun.file(resolve(config.hitokotoDirectory, path)).json()) as { hitokoto?: unknown from?: unknown from_who?: unknown }[] const text = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : null) const rows = (Array.isArray(raw) ? raw : []) .filter((it) => typeof it.hitokoto === 'string' && it.hitokoto.length > 0) .map((it) => ({ hitokoto: it.hitokoto as string, from: text(it.from) ?? '佚名', fromWho: text(it.from_who), type, })) if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`) sentenceCache.set(path, rows) return rows } async function randomQuote() { if (!categories) { const list = (await Bun.file(resolve(config.hitokotoDirectory, 'categories.json')).json()) as { path?: unknown name?: unknown }[] const parsed = list .filter((it): it is { path: string; name?: unknown } => typeof it.path === 'string') .map((it) => ({ path: it.path, name: typeof it.name === 'string' ? it.name : null })) if (parsed.length === 0) throw new Error('no hitokoto categories') categories = parsed } const category = categories[Math.floor(Math.random() * categories.length)]! const sentences = await loadSentences(category.path, category.name) 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 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码 return success( c, rows.map(({ username }) => stripClassPrefix(username, className)), ) })