feat: 实现后端基础架构与 VOD 核心 API(Express + SQLite + JWT)

This commit is contained in:
2026-06-29 17:46:38 +06:00
parent 0192c313f4
commit da06866360
25 changed files with 10525 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
jwtSecret: process.env.JWT_SECRET || 'vod-manager-dev-secret-change-me',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
encryptionKey: process.env.APP_ENCRYPTION_KEY || '',
defaultAdminUsername: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
defaultAdminPassword: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
dbPath: process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'vod-manager.db'),
vodEndpointTemplate: process.env.VOD_ENDPOINT_TEMPLATE || 'vod.{region}.aliyuncs.com',
};
export function validateConfig(): void {
if (config.nodeEnv === 'production' && config.jwtSecret === 'vod-manager-dev-secret-change-me') {
throw new Error('JWT_SECRET must be set in production');
}
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'fs';
import path from 'path';
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
import { config } from './config';
let db: Database<sqlite3.Database, sqlite3.Statement> | null = null;
export async function getDb(): Promise<Database<sqlite3.Database, sqlite3.Statement>> {
if (db) return db;
const dbDir = path.dirname(config.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = await open({
filename: config.dbPath,
driver: sqlite3.Database,
});
await initSchema();
return db;
}
async function initSchema(): Promise<void> {
const database = await getDb();
await database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
access_key_id TEXT NOT NULL,
access_key_secret TEXT NOT NULL,
region TEXT NOT NULL,
endpoint TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active);
CREATE TABLE IF NOT EXISTS operation_logs (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_ids TEXT,
details TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_logs_action ON operation_logs(action);
`);
}
+55
View File
@@ -0,0 +1,55 @@
import express, { Request, Response } from 'express';
import cors from 'cors';
import path from 'path';
import pino from 'pino';
import { config, validateConfig } from './config';
import { getDb } from './db';
import { ensureDefaultAdmin } from './services/users';
import authRoutes from './routes/auth';
import configRoutes from './routes/config';
import videoRoutes from './routes/videos';
import categoryRoutes from './routes/categories';
import logRoutes from './routes/logs';
const logger = pino({
transport: config.nodeEnv === 'development' ? { target: 'pino-pretty' } : undefined,
});
async function main() {
validateConfig();
await getDb();
await ensureDefaultAdmin();
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/accounts', configRoutes);
app.use('/api/videos', videoRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/logs', logRoutes);
// Serve static frontend in production
if (config.nodeEnv === 'production') {
const staticPath = path.resolve(__dirname, '../../web/dist');
app.use(express.static(staticPath));
app.get('*', (_req: Request, res: Response) => {
res.sendFile(path.join(staticPath, 'index.html'));
});
}
app.use((err: Error, _req: Request, res: Response, _next: express.NextFunction) => {
logger.error(err);
res.status(500).json({ code: 500, message: err.message || 'Internal server error' });
});
app.listen(config.port, () => {
logger.info(`Server listening on port ${config.port}`);
});
}
main().catch((err) => {
logger.error(err);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { JwtPayload } from '../types';
export interface AuthenticatedRequest extends Request {
user?: JwtPayload;
}
export function authMiddleware(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ code: 401, message: 'Unauthorized' });
return;
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ code: 401, message: 'Invalid token' });
}
}
export function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'admin') {
res.status(403).json({ code: 403, message: 'Forbidden: admin required' });
return;
}
next();
}
export function requireWrite(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role === 'readonly') {
res.status(403).json({ code: 403, message: 'Forbidden: read-only user' });
return;
}
next();
}
+50
View File
@@ -0,0 +1,50 @@
import { Router } from 'express';
import jwt, { SignOptions } from 'jsonwebtoken';
import { config } from '../config';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { findUserByUsername, verifyPassword } from '../services/users';
const router = Router();
router.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ code: 400, message: 'Username and password are required' });
return;
}
const user = await findUserByUsername(username);
if (!user || !(await verifyPassword(user, password))) {
res.status(401).json({ code: 401, message: 'Invalid credentials' });
return;
}
const signOptions: SignOptions = { expiresIn: config.jwtExpiresIn as SignOptions['expiresIn'] };
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
config.jwtSecret,
signOptions
);
res.json({
code: 200,
data: {
token,
user: {
id: user.id,
username: user.username,
role: user.role,
},
},
});
}));
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res) => {
res.json({
code: 200,
data: req.user,
});
});
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { getActiveAccountAndClient } from '../utils/account';
import { getCategories } from '../services/vod';
const router = Router();
router.get(
'/',
authMiddleware,
asyncHandler(async (_req, res) => {
const { client } = await getActiveAccountAndClient();
const categories = await getCategories(client);
res.json({ code: 200, data: categories });
})
);
export default router;
+115
View File
@@ -0,0 +1,115 @@
import { Router } from 'express';
import { z } from 'zod';
import {
listAccounts,
createAccount,
updateAccount,
deleteAccount,
switchActiveAccount,
} from '../services/config';
import { authMiddleware, requireAdmin } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
const router = Router();
const accountSchema = z.object({
name: z.string().min(1),
accessKeyId: z.string().min(1),
accessKeySecret: z.string().min(1),
region: z.string().min(1),
endpoint: z.string().optional(),
});
router.get(
'/',
authMiddleware,
asyncHandler(async (_req, res) => {
const accounts = await listAccounts();
res.json({ code: 200, data: accounts });
})
);
router.post(
'/',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const parsed = accountSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const account = await createAccount(parsed.data);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
accessKeyId: account.accessKeyId,
region: account.region,
endpoint: account.endpoint,
isActive: account.isActive,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
},
});
})
);
router.put(
'/:id',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const parsed = accountSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const account = await updateAccount(req.params.id, parsed.data);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
accessKeyId: account.accessKeyId,
region: account.region,
endpoint: account.endpoint,
isActive: account.isActive,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
},
});
})
);
router.delete(
'/:id',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
await deleteAccount(req.params.id);
res.json({ code: 200, data: null });
})
);
router.post(
'/:id/switch',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const account = await switchActiveAccount(req.params.id);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
isActive: account.isActive,
},
});
})
);
export default router;
+29
View File
@@ -0,0 +1,29 @@
import { Router } from 'express';
import { z } from 'zod';
import { authMiddleware } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { listLogs } from '../services/logs';
const router = Router();
router.get(
'/',
authMiddleware,
asyncHandler(async (req, res) => {
const schema = z.object({
pageNo: z.coerce.number().optional().default(1),
pageSize: z.coerce.number().max(100).optional().default(20),
action: z.string().optional(),
});
const parsed = schema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const result = await listLogs(parsed.data);
res.json({ code: 200, data: result });
})
);
export default router;
+190
View File
@@ -0,0 +1,190 @@
import { Router } from 'express';
import { z } from 'zod';
import { authMiddleware, AuthenticatedRequest, requireWrite } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { getActiveAccountAndClient } from '../utils/account';
import {
deleteVideos,
getPlayInfo,
getVideoInfo,
searchMedia,
updateVideoInfo,
} from '../services/vod';
import { createLog } from '../services/logs';
const router = Router();
const searchSchema = z.object({
keyword: z.string().optional(),
cateId: z.coerce.number().optional(),
status: z.string().optional(),
pageNo: z.coerce.number().optional().default(1),
pageSize: z.coerce.number().max(100).optional().default(20),
startTime: z.string().optional(),
endTime: z.string().optional(),
sortBy: z.string().optional(),
sortOrder: z.enum(['Asc', 'Desc']).optional().default('Desc'),
});
router.get(
'/',
authMiddleware,
asyncHandler(async (req, res) => {
const parsed = searchSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
const result = await searchMedia(client, parsed.data);
res.json({ code: 200, data: result });
})
);
router.get(
'/:id',
authMiddleware,
asyncHandler(async (req, res) => {
const { client } = await getActiveAccountAndClient();
const video = await getVideoInfo(client, req.params.id);
res.json({ code: 200, data: video });
})
);
router.post(
'/:id/play-info',
authMiddleware,
asyncHandler(async (req, res) => {
const { definition } = req.body || {};
const { client } = await getActiveAccountAndClient();
const playInfo = await getPlayInfo(client, req.params.id, definition);
res.json({ code: 200, data: playInfo });
})
);
router.post(
'/batch-delete',
authMiddleware,
requireWrite,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({ videoIds: z.array(z.string()).min(1).max(100) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
await deleteVideos(client, parsed.data.videoIds);
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_DELETE',
targetType: 'video',
targetIds: parsed.data.videoIds,
details: { count: parsed.data.videoIds.length },
});
res.json({ code: 200, data: { deleted: parsed.data.videoIds.length } });
})
);
router.post(
'/batch-download',
authMiddleware,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({
videoIds: z.array(z.string()).min(1).max(100),
definition: z.string().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
const results: {
videoId: string;
title?: string;
urls: { definition: string; url: string }[];
}[] = [];
for (const videoId of parsed.data.videoIds) {
try {
const [video, playInfo] = await Promise.all([
getVideoInfo(client, videoId),
getPlayInfo(client, videoId, parsed.data.definition),
]);
results.push({
videoId,
title: video.title,
urls: playInfo.map((info) => ({
definition: info.definition,
url: info.playURL,
})),
});
} catch (error) {
results.push({
videoId,
title: undefined,
urls: [],
});
}
}
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_DOWNLOAD',
targetType: 'video',
targetIds: parsed.data.videoIds,
details: { count: parsed.data.videoIds.length, definition: parsed.data.definition },
});
res.json({ code: 200, data: results });
})
);
router.post(
'/batch-update',
authMiddleware,
requireWrite,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({
videoIds: z.array(z.string()).min(1).max(100),
title: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(),
cateId: z.number().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { videoIds, ...updates } = parsed.data;
const { client } = await getActiveAccountAndClient();
for (const videoId of videoIds) {
await updateVideoInfo(client, videoId, updates);
}
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_UPDATE',
targetType: 'video',
targetIds: videoIds,
details: { updates },
});
res.json({ code: 200, data: { updated: videoIds.length } });
})
);
export default router;
+114
View File
@@ -0,0 +1,114 @@
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { Account, AccountInput } from '../types';
import { encrypt } from '../utils/crypto';
const ACCOUNT_COLUMNS =
'id, name, access_key_id as accessKeyId, access_key_secret as accessKeySecret, region, endpoint, is_active as isActive, created_at as createdAt, updated_at as updatedAt';
export async function listAccounts(): Promise<Omit<Account, 'accessKeySecret'>[]> {
const db = await getDb();
const rows = await db.all<Account[]>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts ORDER BY created_at DESC`);
return rows.map((row) => ({
id: row.id,
name: row.name,
accessKeyId: row.accessKeyId,
accessKeySecret: '',
region: row.region,
endpoint: row.endpoint,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
export async function getAccountById(id: string): Promise<Account | undefined> {
const db = await getDb();
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`, id);
}
export async function getActiveAccount(): Promise<Account | undefined> {
const db = await getDb();
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE is_active = 1 LIMIT 1`);
}
export async function createAccount(input: AccountInput): Promise<Account> {
const db = await getDb();
const id = uuidv4();
const now = new Date().toISOString();
await db.run(
`INSERT INTO accounts (id, name, access_key_id, access_key_secret, region, endpoint, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
input.accessKeyId,
encrypt(input.accessKeySecret),
input.region,
input.endpoint || null,
0,
now,
now,
]
);
const account = await getAccountById(id);
if (!account) throw new Error('Failed to create account');
return account;
}
export async function updateAccount(id: string, input: Partial<AccountInput>): Promise<Account> {
const db = await getDb();
const account = await getAccountById(id);
if (!account) throw new Error('Account not found');
const updates: string[] = [];
const values: unknown[] = [];
if (input.name !== undefined) {
updates.push('name = ?');
values.push(input.name);
}
if (input.accessKeyId !== undefined) {
updates.push('access_key_id = ?');
values.push(input.accessKeyId);
}
if (input.accessKeySecret !== undefined) {
updates.push('access_key_secret = ?');
values.push(encrypt(input.accessKeySecret));
}
if (input.region !== undefined) {
updates.push('region = ?');
values.push(input.region);
}
if (input.endpoint !== undefined) {
updates.push('endpoint = ?');
values.push(input.endpoint || null);
}
updates.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(id);
await db.run(`UPDATE accounts SET ${updates.join(', ')} WHERE id = ?`, values);
const updated = await getAccountById(id);
if (!updated) throw new Error('Failed to update account');
return updated;
}
export async function deleteAccount(id: string): Promise<void> {
const db = await getDb();
await db.run('DELETE FROM accounts WHERE id = ?', id);
}
export async function switchActiveAccount(id: string): Promise<Account> {
const db = await getDb();
await db.run('UPDATE accounts SET is_active = 0');
await db.run('UPDATE accounts SET is_active = 1 WHERE id = ?', id);
const account = await getAccountById(id);
if (!account) throw new Error('Account not found');
return account;
}
+72
View File
@@ -0,0 +1,72 @@
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { OperationLog } from '../types';
export async function createLog(params: {
userId?: string;
username?: string;
action: string;
targetType: string;
targetIds?: string[];
details?: Record<string, unknown>;
}): Promise<OperationLog> {
const db = await getDb();
const id = uuidv4();
const now = new Date().toISOString();
await db.run(
`INSERT INTO operation_logs (id, user_id, username, action, target_type, target_ids, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
params.userId || null,
params.username || null,
params.action,
params.targetType,
params.targetIds ? params.targetIds.join(',') : null,
params.details ? JSON.stringify(params.details) : null,
now,
]
);
const log = await db.get<OperationLog>(
'SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs WHERE id = ?',
id
);
if (!log) throw new Error('Failed to create log');
return log;
}
export async function listLogs(options: {
pageNo?: number;
pageSize?: number;
action?: string;
}): Promise<{ total: number; logs: OperationLog[]; pageNo: number; pageSize: number }> {
const db = await getDb();
const pageNo = options.pageNo || 1;
const pageSize = Math.min(options.pageSize || 20, 100);
let whereClause = '';
const params: unknown[] = [];
if (options.action) {
whereClause = 'WHERE action = ?';
params.push(options.action);
}
const countRow = await db.get<{ total: number }>(
`SELECT COUNT(*) as total FROM operation_logs ${whereClause}`,
params
);
const logs = await db.all<OperationLog[]>(
`SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...params, pageSize, (pageNo - 1) * pageSize]
);
return {
total: countRow?.total || 0,
pageNo,
pageSize,
logs,
};
}
+48
View File
@@ -0,0 +1,48 @@
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { User } from '../types';
export async function findUserByUsername(username: string): Promise<User | undefined> {
const db = await getDb();
return db.get<User>(
'SELECT id, username, password_hash as passwordHash, role, created_at as createdAt FROM users WHERE username = ?',
username
);
}
export async function createUser(params: {
username: string;
password: string;
role?: 'admin' | 'readonly';
}): Promise<User> {
const db = await getDb();
const id = uuidv4();
const hash = await bcrypt.hash(params.password, 10);
const now = new Date().toISOString();
await db.run(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)',
[id, params.username, hash, params.role || 'admin', now]
);
const user = await findUserByUsername(params.username);
if (!user) throw new Error('Failed to create user');
return user;
}
export async function verifyPassword(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.passwordHash);
}
export async function ensureDefaultAdmin(): Promise<void> {
const db = await getDb();
const count = await db.get<{ total: number }>('SELECT COUNT(*) as total FROM users');
if ((count?.total || 0) === 0) {
await createUser({
username: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
password: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
role: 'admin',
});
}
}
+174
View File
@@ -0,0 +1,174 @@
import RPCClient from '@alicloud/pop-core';
import { Account, PlayInfo, VideoItem } from '../types';
import { decrypt } from '../utils/crypto';
export interface SearchParams {
keyword?: string;
cateId?: number;
status?: string;
pageNo?: number;
pageSize?: number;
startTime?: string;
endTime?: string;
sortBy?: string;
sortOrder?: 'Asc' | 'Desc';
}
export interface SearchResult {
total: number;
pageNo: number;
pageSize: number;
videos: VideoItem[];
}
export function createVodClient(account: Account): RPCClient {
const endpoint = account.endpoint || `vod.${account.region}.aliyuncs.com`;
return new RPCClient({
accessKeyId: account.accessKeyId,
accessKeySecret: decrypt(account.accessKeySecret),
endpoint,
apiVersion: '2017-03-21',
});
}
export async function searchMedia(client: RPCClient, params: SearchParams): Promise<SearchResult> {
const requestParams: Record<string, unknown> = {
PageNo: params.pageNo || 1,
PageSize: Math.min(params.pageSize || 20, 100),
};
const searchParams: string[] = [];
if (params.keyword) {
searchParams.push(`Title='${escapeSearch(params.keyword)}'`);
}
if (params.cateId) {
searchParams.push(`CateId=${params.cateId}`);
}
if (params.status) {
searchParams.push(`Status='${params.status}'`);
}
if (params.startTime && params.endTime) {
searchParams.push(`CreationTime>${params.startTime}`);
searchParams.push(`CreationTime<${params.endTime}`);
}
if (searchParams.length > 0) {
requestParams.SearchType = 'video';
requestParams.Fields = 'Title,CoverURL,CreationTime,Status,Duration,Size,CateId,CateName,Tags';
requestParams.Match = searchParams.join(' and ');
requestParams.SortBy = params.sortBy || 'CreationTime';
requestParams.SortOrder = params.sortOrder || 'Desc';
}
const action = 'SearchMedia';
const response = (await client.request(action, requestParams, { method: 'POST' })) as Record<
string,
unknown
>;
const mediaListRaw = (response.MediaList as Record<string, unknown>[]) || [];
const mediaList = mediaListRaw.map((item) => ({
videoId: (item.VideoId || item.MediaId) as string,
title: item.Title as string,
description: item.Description as string,
duration: item.Duration as number,
size: item.Size as number,
coverURL: item.CoverURL as string,
status: item.Status as string,
cateId: item.CateId as number,
cateName: item.CateName as string,
tags: item.Tags as string,
creationTime: item.CreationTime as string,
modificationTime: item.ModificationTime as string,
}));
return {
total: (response.Total as number) || 0,
pageNo: params.pageNo || 1,
pageSize: params.pageSize || 20,
videos: mediaList,
};
}
export async function getVideoInfo(client: RPCClient, videoId: string): Promise<VideoItem> {
const response = (await client.request(
'GetVideoInfo',
{ VideoId: videoId },
{ method: 'POST' }
)) as Record<string, unknown>;
const video = response.Video as Record<string, unknown>;
return {
videoId: video.VideoId as string,
title: video.Title as string,
description: video.Description as string,
duration: video.Duration as number,
size: video.Size as number,
coverURL: video.CoverURL as string,
status: video.Status as string,
cateId: video.CateId as number,
cateName: video.CateName as string,
tags: video.Tags as string,
creationTime: video.CreationTime as string,
modificationTime: video.ModificationTime as string,
};
}
export async function getPlayInfo(
client: RPCClient,
videoId: string,
definition?: string
): Promise<PlayInfo[]> {
const params: Record<string, unknown> = { VideoId: videoId };
if (definition) {
params.Definition = definition;
}
const response = (await client.request('GetPlayInfo', params, {
method: 'POST',
})) as Record<string, unknown>;
const playInfoList = (response.PlayInfoList as Record<string, unknown>) || {};
const list = (playInfoList.PlayInfo as Record<string, unknown>[]) || [];
return list.map((item) => ({
playURL: item.PlayURL as string,
definition: item.Definition as string,
duration: item.Duration as string,
size: item.Size as number,
format: item.Format as string,
}));
}
export async function deleteVideos(client: RPCClient, videoIds: string[]): Promise<void> {
await client.request(
'DeleteVideo',
{ VideoIds: videoIds.join(',') },
{ method: 'POST' }
);
}
export async function updateVideoInfo(
client: RPCClient,
videoId: string,
updates: { title?: string; description?: string; tags?: string; cateId?: number }
): Promise<void> {
const params: Record<string, unknown> = { VideoId: videoId };
if (updates.title !== undefined) params.Title = updates.title;
if (updates.description !== undefined) params.Description = updates.description;
if (updates.tags !== undefined) params.Tags = updates.tags;
if (updates.cateId !== undefined) params.CateId = updates.cateId;
await client.request('UpdateVideoInfo', params, { method: 'POST' });
}
export async function getCategories(client: RPCClient): Promise<Record<string, unknown>[]> {
const response = (await client.request('GetCategories', { PageSize: 100 }, {
method: 'POST',
})) as Record<string, unknown>;
const subCategory = (response.SubCategory as Record<string, unknown>) || {};
return (subCategory.SubCategory as Record<string, unknown>[]) || [];
}
function escapeSearch(value: string): string {
return value.replace(/'/g, "\\'");
}
+67
View File
@@ -0,0 +1,67 @@
export interface User {
id: string;
username: string;
passwordHash: string;
role: 'admin' | 'readonly';
createdAt: string;
}
export interface Account {
id: string;
name: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
endpoint?: string;
isActive: number;
createdAt: string;
updatedAt: string;
}
export interface AccountInput {
name: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
endpoint?: string;
}
export interface OperationLog {
id: string;
userId: string | null;
username: string | null;
action: string;
targetType: string;
targetIds: string;
details: string;
createdAt: string;
}
export interface JwtPayload {
userId: string;
username: string;
role: string;
}
export interface VideoItem {
videoId: string;
title: string;
description?: string;
duration?: number;
size?: number;
coverURL?: string;
status?: string;
cateId?: number;
cateName?: string;
tags?: string;
creationTime?: string;
modificationTime?: string;
}
export interface PlayInfo {
playURL: string;
definition: string;
duration: string;
size: number;
format: string;
}
+24
View File
@@ -0,0 +1,24 @@
declare module '@alicloud/pop-core' {
interface RPCClientOptions {
accessKeyId: string;
accessKeySecret: string;
endpoint: string;
apiVersion: string;
}
interface RequestOptions {
method?: 'GET' | 'POST';
timeout?: number;
}
class RPCClient {
constructor(options: RPCClientOptions);
request<T = Record<string, unknown>>(
action: string,
params: Record<string, unknown>,
options?: RequestOptions
): Promise<T>;
}
export default RPCClient;
}
+17
View File
@@ -0,0 +1,17 @@
import { Account } from '../types';
import { createVodClient } from '../services/vod';
import { getActiveAccount } from '../services/config';
import RPCClient from '@alicloud/pop-core';
export async function getActiveAccountAndClient(): Promise<{
account: Account;
client: RPCClient;
}> {
const account = await getActiveAccount();
if (!account) {
throw new Error('No active account configured');
}
const client = createVodClient(account);
return { account, client };
}
+9
View File
@@ -0,0 +1,9 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { encrypt, decrypt } from './crypto';
describe('crypto utils', () => {
it('should encrypt and decrypt text when key is set', () => {
process.env.APP_ENCRYPTION_KEY = 'my-32-char-encryption-key-12345';
const text = 'super-secret-access-key';
const encrypted = encrypt(text);
expect(encrypted).not.toBe(text);
expect(encrypted).toContain('enc:');
const decrypted = decrypt(encrypted);
expect(decrypted).toBe(text);
});
});
+44
View File
@@ -0,0 +1,44 @@
import crypto from 'crypto';
import { config } from '../config';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
function getKey(): Buffer | null {
if (!config.encryptionKey) return null;
return crypto.scryptSync(config.encryptionKey, 'vod-manager-salt', 32);
}
export function encrypt(text: string): string {
const key = getKey();
if (!key) return `plain:${text}`;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
export function decrypt(text: string): string {
if (text.startsWith('plain:')) return text.slice(6);
if (!text.startsWith('enc:')) return text;
const key = getKey();
if (!key) {
throw new Error('APP_ENCRYPTION_KEY is required to decrypt secrets');
}
const parts = text.split(':');
if (parts.length !== 4) throw new Error('Invalid encrypted text format');
const iv = Buffer.from(parts[1], 'hex');
const authTag = Buffer.from(parts[2], 'hex');
const encrypted = Buffer.from(parts[3], 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}