Files
OJ2/apps/api/src/routes/site.ts
T
xuyueandClaude Opus 5.5 adea781b8e
Deploy / deploy (push) Canceled after 0s
fix(一言): 句子下面多挂了个「· 游戏」「· 动画」的分类,看着啰嗦——不显示分类;本机不用再配 env 就能读到一言数据集
- 出处只写「—— 谁《出处》」。接口里的分类还在,只用来判断出处要不要加书名号
- HITOKOTO_DIRECTORY 默认值改成仓库旁边的 ../sentences-bundle,.env.example 里那几行撤掉。
  线上不受影响:镜像里 Dockerfile 写死了 /data/hitokoto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:14:03 +08:00

124 lines
4.7 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)
})
// 数据集读不到时的兜底(本机旁边没有 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)),
)
})