1
0

add: OpenAPI description for backend

This commit is contained in:
alikia2x (寒寒) 2025-11-17 05:47:13 +08:00
parent a95b9f76be
commit 5560f5d705
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
14 changed files with 170 additions and 72 deletions

View File

@ -68,13 +68,9 @@ export async function createSession(
export async function validateSession(
sessionId: string
): Promise<{ user: User; session: any } | null> {
const session = await db
.select({
...getTableColumns(usersInCredentials),
...getTableColumns(loginSessionsInCredentials)
})
const sessions = await db
.select()
.from(loginSessionsInCredentials)
.innerJoin(usersInCredentials, eq(loginSessionsInCredentials.uid, usersInCredentials.id))
.where(
and(
eq(loginSessionsInCredentials.id, sessionId),
@ -83,23 +79,23 @@ export async function validateSession(
)
.limit(1);
if (session.length === 0) {
if (sessions.length === 0) {
return null;
}
const foundSession = session[0];
const session = sessions[0];
if (foundSession.expireAt && new Date(foundSession.expireAt) < new Date()) {
if (session.expireAt && new Date(session.expireAt) < new Date()) {
return null;
}
const user = await db
const users = await db
.select()
.from(usersInCredentials)
.where(eq(usersInCredentials.id, foundSession.uid))
.where(eq(usersInCredentials.id, session.uid))
.limit(1);
if (user.length === 0) {
if (users.length === 0) {
return null;
}
@ -109,8 +105,8 @@ export async function validateSession(
.where(eq(loginSessionsInCredentials.id, sessionId));
return {
user: user[0],
session: foundSession
user: users[0],
session: session
};
}

View File

@ -30,6 +30,8 @@ export const requireAuth = new Elysia({ name: "require-auth" })
const authHeader = headers.authorization;
if (authHeader.startsWith("Bearer ")) {
sessionId = authHeader.substring(7);
} else if (authHeader.startsWith("Token ")) {
sessionId = authHeader.substring(6);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.1.0",
"scripts": {
"dev": "NODE_ENV=development bun run --watch src/index.ts",
"start": "NODE_ENV=production bun run src/index.ts",

View File

@ -16,7 +16,7 @@ export const loginHandler = new Elysia({ prefix: "/auth" }).use(ip()).post(
const userAgent = request.headers.get("user-agent") || "Unknown";
const sessionId = await createSession(user.id, ip || null, userAgent);
const expiresAt = getSessionExpirationDate();
const expiresAt = getSessionExpirationDate(365);
cookie.sessionId.value = sessionId;
cookie.sessionId.httpOnly = true;
cookie.sessionId.secure = process.env.NODE_ENV === "production";
@ -53,6 +53,13 @@ export const loginHandler = new Elysia({ prefix: "/auth" }).use(ip()).post(
body: t.Object({
username: t.String(),
password: t.String()
})
}),
detail: {
summary: "User login",
description:
"This endpoint authenticates users by verifying their credentials and creates a new session. \
Upon successful authentication, it returns user information and sets a secure HTTP-only cookie \
for session management. The session includes IP address and user agent tracking for security purposes."
}
}
);

View File

@ -1,50 +1,37 @@
import { Elysia, t } from "elysia";
import { deactivateSession } from "@backend/lib/auth";
import requireAuth from "@backend/middlewares/auth";
export const logoutHandler = new Elysia({ prefix: "/auth" })
.delete(
"/session",
async ({ set, cookie }) => {
const sessionId = cookie.sessionId?.value;
export const logoutHandler = new Elysia({ prefix: "/auth" }).use(requireAuth).delete(
"/session",
async ({ set, session, cookie }) => {
const sessionId = session.sessionId;
if (!sessionId) {
set.status = 401;
return { message: "Not authenticated." };
}
await deactivateSession(sessionId as string);
cookie.sessionId.remove();
return { message: "Successfully logged out." };
},
{
response: {
200: t.Object({
message: t.String()
}),
401: t.Object({
message: t.String()
})
}
if (!sessionId) {
set.status = 401;
return { message: "Not authenticated." };
}
)
.delete(
"/session/:id",
async ({ params }) => {
const sessionId = params.id;
await deactivateSession(sessionId as string);
await deactivateSession(sessionId as string);
cookie.sessionId.remove();
return { message: "Successfully logged out." };
return { message: "Successfully logged out." };
},
{
response: {
200: t.Object({
message: t.String()
}),
401: t.Object({
message: t.String()
})
},
{
response: {
200: t.Object({
message: t.String()
}),
401: t.Object({
message: t.String()
})
}
detail: {
summary: "Logout current session",
description:
"This endpoint logs out the current user by deactivating their session and removing the session cookie. \
It requires an active session cookie to be present in the request. After successful logout, the session \
is invalidated and cannot be used again."
}
);
}
);

View File

@ -150,6 +150,14 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get(
},
query: z.object({
query: z.string()
})
}),
detail: {
summary: "Search songs and videos",
description:
"This endpoint performs a comprehensive search across songs and videos in the database. \
It searches for songs by name and videos by bilibili ID (av/BV format). The results are ranked \
by relevance using a weighted algorithm that considers search term frequency, title length, \
and view count. Returns search results with performance timing information."
}
}
);

View File

@ -62,7 +62,15 @@ export const addSongHandler = new Elysia()
},
body: t.Object({
id: t.String()
})
}),
detail: {
summary: "Import song from bilibili",
description:
"This endpoint allows authenticated users to import a song from bilibili by providing a video ID. \
The video ID can be in av or BV format. The system validates the ID format, checks if the video already \
exists in the database, and if not, creates a background job to fetch video metadata and add it to the songs collection. \
Returns the job ID for tracking the import progress."
}
}
)
.get(
@ -106,6 +114,13 @@ export const addSongHandler = new Elysia()
},
params: t.Object({
id: t.String()
})
}),
detail: {
summary: "Check import job status",
description:
"This endpoint retrieves the current status of a song import job. It returns the job state \
(completed, failed, active, etc.), the result if completed, and any failure reason if the job failed. \
Use this endpoint to monitor the progress of song imports initiated through the import endpoint."
}
}
);

View File

@ -32,6 +32,13 @@ export const deleteSongHandler = new Elysia({ prefix: "/song" }).use(requireAuth
},
params: t.Object({
id: t.String()
})
}),
detail: {
summary: "Delete song",
description:
"This endpoint allows authenticated users to soft-delete a song from the database. \
The song is marked as deleted rather than being permanently removed, preserving data integrity. \
The deletion is logged in the history table for audit purposes. Requires authentication and appropriate permissions."
}
}
);

View File

@ -158,7 +158,15 @@ const songInfoUpdateHandler = new Elysia({ prefix: "/song" }).use(requireAuth).p
body: t.Object({
name: t.Optional(t.String()),
producer: t.Optional(t.String())
})
}),
detail: {
summary: "Update song information",
description:
"This endpoint allows authenticated users to update song metadata. It accepts partial updates \
for song name and producer fields. The endpoint validates the song ID (accepting both internal database IDs \
and bilibili video IDs in av/BV format), applies the requested changes, and logs the update in the history table \
for audit purposes. Requires authentication."
}
}
);

View File

@ -50,6 +50,14 @@ export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(server
404: t.Object({
message: t.String()
})
},
detail: {
summary: "Get songs close to milestones",
description:
"This endpoint retrieves songs that are approaching significant view count milestones. \
It supports three milestone types: 'dendou' (0-100k views), 'densetsu' (100k-1M views), and 'shinwa' (1M-10M views). \
For each type, it returns videos that are within the specified view range and have an estimated time to reach \
the next milestone below the threshold. Results are ordered by estimated time to milestone."
}
}
);

View File

@ -49,6 +49,14 @@ export const songEtaHandler = new Elysia({ prefix: "/video" }).get(
},
headers: t.Object({
Authorization: t.Optional(t.String())
})
}),
detail: {
summary: "Get video milestone ETA",
description:
"This endpoint retrieves the estimated time to reach the next milestone for a given video. \
It accepts video IDs in av or BV format and returns the current view count, estimated time to \
reach the next milestone (in hours), view growth speed, and last update timestamp. Useful for \
tracking video growth and milestone predictions."
}
}
);

View File

@ -88,6 +88,14 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
200: t.Any(),
400: ErrorResponseSchema,
500: ErrorResponseSchema
},
detail: {
summary: "Get video metadata",
description:
"This endpoint retrieves comprehensive metadata for a bilibili video. It accepts video IDs in av or BV format \
and returns detailed information including title, description, uploader, statistics (views, likes, coins, etc.), \
and publication date. The data is cached for 60 seconds to reduce API calls. If the video is not in cache, \
it fetches fresh data from bilibili API and stores a snapshot in the database."
}
}
);

View File

@ -57,6 +57,14 @@ export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get(
),
400: ErrorResponseSchema,
500: ErrorResponseSchema
},
detail: {
summary: "Get video snapshots",
description:
"This endpoint retrieves historical view count snapshots for a bilibili video. It accepts video IDs in av or BV format \
and returns a chronological list of snapshots showing how the video's statistics (views, likes, coins, favorites, etc.) \
have changed over time. If no snapshots exist for the video, it automatically queues a snapshot job to collect initial data. \
Results are ordered by creation date in descending order."
}
}
);

View File

@ -16,6 +16,8 @@ import { deleteSongHandler } from "@backend/routes/song/delete";
import { songEtaHandler } from "@backend/routes/video/eta";
import "./mq";
import pkg from "../package.json";
import * as z from "zod";
import { fromTypes } from "@elysiajs/openapi";
const [host, port] = getBindingInfo();
logStartup(host, port);
@ -35,7 +37,25 @@ const app = new Elysia({
})
.use(onAfterHandler)
.use(cors())
.use(openapi())
.use(
openapi({
documentation: {
info: {
title: "CVSA API Docs",
version: pkg.version
}
},
mapJsonSchema: {
zod: z.toJSONSchema
},
references: fromTypes(),
scalar: {
theme: "kepler",
hideClientButton: true,
hideDarkModeToggle: true
}
})
)
.use(rootHandler)
.use(pingHandler)
.use(authHandler)
@ -47,13 +67,29 @@ const app = new Elysia({
.use(addSongHandler)
.use(deleteSongHandler)
.use(songEtaHandler)
.get("/song/:id", ({ redirect, params }) => {
console.log(`/song/${params.id}/info`);
return redirect(`/song/${params.id}/info`, 302);
})
.get("/video/:id", ({ redirect, params }) => {
return redirect(`/video/${params.id}/info`, 302);
})
.get(
"/song/:id",
({ redirect, params }) => {
console.log(`/song/${params.id}/info`);
return redirect(`/song/${params.id}/info`, 302);
},
{
detail: {
hide: true
}
}
)
.get(
"/video/:id",
({ redirect, params }) => {
return redirect(`/video/${params.id}/info`, 302);
},
{
detail: {
hide: true
}
}
)
.listen(15412);
export const VERSION = pkg.version;