Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
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<string, Quote[]>()
|
||
|
||
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)),
|
||
)
|
||
})
|