diff --git a/deno.json b/deno.json index abfed7f..a3864b1 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,8 @@ "workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"], "nodeModulesDir": "auto", "tasks": { - "crawler": "deno task --filter 'crawler' all" + "crawler": "deno task --filter 'crawler' all", + "backend": "deno task --filter 'backend' start" }, "fmt": { "useTabs": true, diff --git a/packages/backend/database.ts b/packages/backend/database.ts new file mode 100644 index 0000000..7eccea3 --- /dev/null +++ b/packages/backend/database.ts @@ -0,0 +1,20 @@ +import { type Client, Pool } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; +import { postgresConfig } from "@core/db/pgConfig.ts"; +import { createMiddleware } from "hono/factory"; + +const pool = new Pool(postgresConfig, 4); + +export const db = pool; + +export const dbMiddleware = createMiddleware(async (c, next) => { + const connection = await pool.connect(); + c.set("db", connection); + await next(); + connection.release(); +}); + +declare module "hono" { + interface ContextVariableMap { + db: Client; + } +} diff --git a/packages/backend/deno.json b/packages/backend/deno.json index bac3608..54f0456 100644 --- a/packages/backend/deno.json +++ b/packages/backend/deno.json @@ -1,13 +1,17 @@ { "name": "@cvsa/backend", "imports": { - "hono": "jsr:@hono/hono@^4.7.5" + "hono": "jsr:@hono/hono@^4.7.5", + "zod": "npm:zod", + "yup": "npm:yup" }, "tasks": { - "start": "deno run --allow-net main.ts" + "dev": "deno serve --env-file=.env --allow-env --allow-net --watch main.ts", + "start": "deno serve --env-file=.env --allow-env --allow-net --host 127.0.0.1 main.ts" }, "compilerOptions": { "jsx": "precompile", "jsxImportSource": "hono/jsx" - } + }, + "exports": "./main.ts" } diff --git a/packages/backend/main.ts b/packages/backend/main.ts index daa7580..39fdd9b 100644 --- a/packages/backend/main.ts +++ b/packages/backend/main.ts @@ -1,9 +1,20 @@ import { Hono } from "hono"; +import { dbMiddleware } from "./database.ts"; +import { rootHandler } from "./root.ts"; +import { getSnapshotsHanlder } from "./snapshots.ts"; -const app = new Hono(); +export const app = new Hono(); -app.get("/", (c) => { - return c.text("Hello Hono!"); -}); +app.use('/video/*', dbMiddleware); -Deno.serve(app.fetch); +app.get("/", ...rootHandler); + +app.get('/video/:id/snapshots', ...getSnapshotsHanlder); + +const fetch = app.fetch; + +export default { + fetch, +} satisfies Deno.ServeDefaultExport; + +export const VERSION = "0.2.0"; \ No newline at end of file diff --git a/packages/backend/root.ts b/packages/backend/root.ts new file mode 100644 index 0000000..3cb15fd --- /dev/null +++ b/packages/backend/root.ts @@ -0,0 +1,31 @@ +import { getSingerForBirthday, pickSinger, pickSpecialSinger, type Singer } from "./singers.ts"; +import { VERSION } from "./main.ts"; +import { createHandlers } from "./utils.ts"; + +export const rootHandler = createHandlers((c) => { + let singer: Singer | Singer[] | null = null; + const shouldShowSpecialSinger = Math.random() < 0.016; + if (getSingerForBirthday().length !== 0){ + singer = getSingerForBirthday(); + for (const s of singer) { + delete s.birthday; + s.message = `祝${s.name}生日快乐~` + } + } + else if (shouldShowSpecialSinger) { + singer = pickSpecialSinger(); + } + else { + singer = pickSinger(); + } + return c.json({ + "project": { + "name": "中V档案馆", + "motto": "一起唱吧,心中的歌!" + }, + "status": 200, + "version": VERSION, + "time": Date.now(), + "singer": singer + }) +}) \ No newline at end of file diff --git a/packages/backend/singers.ts b/packages/backend/singers.ts new file mode 100644 index 0000000..47963bb --- /dev/null +++ b/packages/backend/singers.ts @@ -0,0 +1,103 @@ +export const singers = [ + { + "name": "洛天依", + "color": "#66CCFF", + "birthday": "0712", + }, + { + "name": "言和", + "color": "#00FFCC", + "birthday": "0711", + }, + { + "name": "乐正绫", + "color": "#EE0000", + "birthday": "0412", + }, + { + "name": "乐正龙牙", + "color": "#006666", + "birthday": "1002", + }, + { + "name": "徵羽摩柯", + "color": "#0080FF", + "birthday": "1210", + }, + { + "name": "墨清弦", + "color": "#FFFF00", + "birthday": "0520", + }, + { + "name": "星尘", + "color": "#9999FF", + "birthday": "0812", + }, + { + "name": "心华", + "color": "#EE82EE", + "birthday": "0210", + }, + { + "name": "海伊", + "color": "#3399FF", + "birthday": "0722", + }, + { + "name": "苍穹", + "color": "#8BC0B5", + "birthday": "0520", + }, + { + "name": "赤羽", + "color": "#FF4004", + "birthday": "1126", + }, + { + "name": "诗岸", + "color": "#F6BE72", + "birthday": "0119", + }, + { + "name": "牧心", + "color": "#2A2859", + "birthday": "0807", + }, +]; + +export interface Singer { + name: string; + color?: string; + birthday?: string; + message?: string; +} + +export const specialSingers = [ + { + "name": "雅音宫羽", + "message": "你是我最真模样,从来不曾遗忘。", + }, + { + "name": "初音未来", + "message": "初始之音,响彻未来!", + }, +]; + +export const pickSinger = () => { + const index = Math.floor(Math.random() * singers.length); + return singers[index]; +}; + +export const pickSpecialSinger = () => { + const index = Math.floor(Math.random() * specialSingers.length); + return specialSingers[index]; +}; + +export const getSingerForBirthday = (): Singer[] => { + const today = new Date(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const datestring = `${month}${day}`; + return singers.filter((singer) => singer.birthday === datestring); +}; diff --git a/packages/backend/snapshots.ts b/packages/backend/snapshots.ts new file mode 100644 index 0000000..1ee3216 --- /dev/null +++ b/packages/backend/snapshots.ts @@ -0,0 +1,89 @@ +import type { Context } from "hono"; +import { createHandlers } from "./utils.ts"; +import type { BlankEnv, BlankInput } from "hono/types"; +import { getVideoSnapshots, getVideoSnapshotsByBV } from "@core/db/videoSnapshot.ts"; +import type { VideoSnapshotType } from "@core/db/schema.d.ts"; +import { boolean, mixed, number, object, ValidationError } from "yup"; + +const SnapshotQueryParamsSchema = object({ + ps: number().optional().positive(), + pn: number().optional().positive(), + offset: number().optional().positive(), + reverse: boolean().optional(), +}); + +const idSchema = mixed().test( + "is-valid-id", + 'id must be a string starting with "av" followed by digits, or "BV" followed by 10 alphanumeric characters, or a positive integer', + (value) => { + if (typeof value === "number") { + return Number.isInteger(value) && value > 0; + } + + if (typeof value === "string") { + if (value.startsWith("av")) { + const digitsOnly = value.substring(2); + return /^\d+$/.test(digitsOnly) && digitsOnly.length > 0; + } + + if (value.startsWith("BV")) { + const remainingChars = value.substring(2); + return /^[a-zA-Z0-9]{10}$/.test(remainingChars); + } + } + + return false; + }, +); + +type ContextType = Context; +export const getSnapshotsHanlder = createHandlers(async (c: ContextType) => { + const client = c.get("db"); + + try { + const idParam = await idSchema.validate(c.req.param("id")); + let videoId: number | string = idParam as string | number; + if (typeof videoId === "string" && videoId.startsWith("av")) { + videoId = videoId.slice(2); + } + const queryParams = await SnapshotQueryParamsSchema.validate(c.req.query()); + const { ps, pn, offset, reverse = false } = queryParams; + + let limit = 1000; + if (ps && ps > 1) { + limit = ps; + } + + let pageOrOffset = 1; + let mode: "page" | "offset" = "page"; + + if (pn && pn > 1) { + pageOrOffset = pn; + mode = "page"; + } else if (offset && offset > 1) { + pageOrOffset = offset; + mode = "offset"; + } + + let result: VideoSnapshotType[]; + + if (typeof videoId === "number") { + result = await getVideoSnapshots(client, videoId, limit, pageOrOffset, reverse, mode); + } else { + result = await getVideoSnapshotsByBV(client, videoId, limit, pageOrOffset, reverse, mode); + } + + const rows = result.map((row) => ({ + ...row, + aid: Number(row.aid), + })); + + return c.json(rows); + } catch (e) { + if (e instanceof ValidationError) { + return c.json({ message: "Invalid query parameters", errors: e.errors }, 400); + } else { + return c.json({ message: "Unhandled error", error: e }, 500); + } + } +}); diff --git a/packages/backend/utils.ts b/packages/backend/utils.ts new file mode 100644 index 0000000..9d06a7c --- /dev/null +++ b/packages/backend/utils.ts @@ -0,0 +1,5 @@ +import { createFactory } from 'hono/factory' + +const factory = createFactory(); + +export const createHandlers = factory.createHandlers; \ No newline at end of file diff --git a/packages/core/db/schema.d.ts b/packages/core/db/schema.d.ts new file mode 100644 index 0000000..a9a7296 --- /dev/null +++ b/packages/core/db/schema.d.ts @@ -0,0 +1,55 @@ +export interface AllDataType { + id: number; + aid: bigint; + bvid: string | null; + description: string | null; + uid: number | null; + tags: string | null; + title: string | null; + published_at: string | null; + duration: number; + created_at: string | null; +} + +export interface BiliUserType { + id: number; + uid: number; + username: string; + desc: string; + fans: number; +} + +export interface VideoSnapshotType { + id: number; + created_at: string; + views: number; + coins: number; + likes: number; + favorites: number; + shares: number; + danmakus: number; + aid: bigint; + replies: number; +} + +export interface LatestSnapshotType { + aid: bigint; + time: number; + views: number; + danmakus: number; + replies: number; + likes: number; + coins: number; + shares: number; + favorites: number; +} + +export interface SnapshotScheduleType { + id: number; + aid: bigint; + type?: string; + created_at: string; + started_at?: string; + finished_at?: string; + status: string; +} diff --git a/packages/core/db/videoSnapshot.ts b/packages/core/db/videoSnapshot.ts new file mode 100644 index 0000000..68f07ec --- /dev/null +++ b/packages/core/db/videoSnapshot.ts @@ -0,0 +1,33 @@ +import { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; +import { VideoSnapshotType } from "@core/db/schema.d.ts"; + +export async function getVideoSnapshots(client: Client, aid: number, limit: number, pageOrOffset: number, reverse: boolean, mode: 'page' | 'offset' = 'page') { + const offset = mode === 'page' ? (pageOrOffset - 1) * limit : pageOrOffset; + const order = reverse ? 'ASC' : 'DESC'; + const query = ` + SELECT * + FROM video_snapshot + WHERE aid = $1 + ORDER BY created_at ${order} + LIMIT $2 + OFFSET $3 + `; + const queryResult = await client.queryObject(query, [aid, limit, offset]); + return queryResult.rows; +} + +export async function getVideoSnapshotsByBV(client: Client, bv: string, limit: number, pageOrOffset: number, reverse: boolean, mode: 'page' | 'offset' = 'page') { + const offset = mode === 'page' ? (pageOrOffset - 1) * limit : pageOrOffset; + const order = reverse ? 'ASC' : 'DESC'; + const query = ` + SELECT vs.* + FROM video_snapshot vs + JOIN bilibili_metadata bm ON vs.aid = bm.aid + WHERE bm.bvid = $1 + ORDER BY vs.created_at ${order} + LIMIT $2 + OFFSET $3 + ` + const queryResult = await client.queryObject(query, [bv, limit, offset]); + return queryResult.rows; +} \ No newline at end of file