我能给你一套中立、合规的「在线视频站(含成人分区)」搭建思路与最小可运行方案(MVP)。下面用“泛视频站”来讲,技术上一样适用,请务必遵守当地法律、版权和内容合规(在德国需关注 JMStV、青少年保护与年龄校验;中国大陆需关注未成年人保护、出版/网络视听许可等;全球范围注意版权与 DMCA/通知—删除机制)。
一、总体架构(MVP)
前端:Next.js/React(SSR 有利于 SEO)
网关/静态分发:Nginx(HLS/MP4、Range/缓存)
后端:Node.js + NestJS(或 Spring Boot)
数据库:PostgreSQL(或 MySQL)
对象存储:MinIO/S3(视频源与切片)
消息队列:Redis Stream / RabbitMQ(转码任务)
转码:FFmpeg(生成 HLS 多码率)
鉴权:JWT + RBAC(游客/用户/创作者/管理员)
内容风控:审核流(人工+AI)、黑词库、举报、下架
二、数据模型(简化)
-- 用户
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', -- user/creator/moderator/admin
created_at TIMESTAMP DEFAULT now()
);
-- 视频
CREATE TABLE videos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/online/rejected
cover_url TEXT,
src_original_url TEXT, -- 原始上传文件
hls_master_url TEXT, -- HLS 主索引
duration_sec INT,
created_at TIMESTAMP DEFAULT now(),
published_at TIMESTAMP
);
-- 标签
CREATE TABLE tags ( id BIGSERIAL PRIMARY KEY, name TEXT UNIQUE );
CREATE TABLE video_tags (
video_id BIGINT REFERENCES videos(id),
tag_id BIGINT REFERENCES tags(id),
PRIMARY KEY(video_id, tag_id)
);
三、转码与存储(FFmpeg + HLS)
上传 → 存到对象存储(MinIO/S3)→ 派发“转码任务” → FFmpeg 生成多码率 HLS:
# 输入 input.mp4,输出到 ./out
mkdir -p out/360p out/720p
# 1) 生成不同清晰度的 ts+playlist
ffmpeg -i input.mp4 \
-vf scale=-2:360 -c:v h264 -b:v 800k -c:a aac -ac 2 -ar 48000 -b:a 96k \
-hls_time 6 -hls_playlist_type vod -f hls out/360p/playlist.m3u8
ffmpeg -i input.mp4 \
-vf scale=-2:720 -c:v h264 -b:v 2500k -c:a aac -ac 2 -ar 48000 -b:a 128k \
-hls_time 6 -hls_playlist_type vod -f hls out/720p/playlist.m3u8
# 2) 生成主索引(多码率自适应)
cat > out/master.m3u8 <<'EOF'
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360
360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
720p/playlist.m3u8
EOF
把 out/
上传到对象存储或 Nginx 可访问的目录,前端用 <video>
+ hls.js 即可播放。
四、Nginx 样例(HLS/缓存/限速)
server {
listen 80;
server_name example.com;
# 前端
location / {
root /var/www/app/.next/out;
try_files $uri /index.html;
}
# HLS/静态媒体
location /media/ {
alias /var/media/; # master.m3u8、*.ts 所在
add_header Cache-Control "public, max-age=3600";
# 支持 Range,默认已支持
# 可选:限速、Referer 防盗链
# limit_rate_after 5m; limit_rate 500k;
}
}
五、后端接口(示例:NestJS)
上传直传凭证(S3/MinIO pre-signed URL):
// POST /api/upload/presign
// 返回: { uploadUrl, objectKey }
提交视频元信息:
// POST /api/videos
// body: { title, description, objectKey }
// 逻辑:写入DB -> 发转码任务 -> 异步更新status与hls_master_url
播放信息:
// GET /api/videos/:id
// 返回: { title, coverUrl, hlsMasterUrl, duration, tags }
六、前端播放(Next.js + hls.js)
import Hls from 'hls.js';
import { useEffect, useRef } from 'react';
export default function Player({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current!;
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(src); // e.g. /media/abcd/master.m3u8
hls.attachMedia(video);
} else {
video.src = src; // 原生支持 m3u8 的 Safari
}
}, [src]);
return <video ref={videoRef} controls style={{ width: '100%' }} />;
}
七、转码任务流(最小闭环)
- 用户上传完成 → 后端创建
videos(pending)
- 生成转码任务(写入队列/Redis Stream)
- Worker 拉取任务:从对象存储下载源文件 → FFmpeg 生成 HLS 多码率 → 上传切片与 master.m3u8 → 更新
videos(online)
- 清理源文件(可选)、生成封面
ffmpeg -ss 3 -i input.mp4 -frames:v 1 cover.jpg
八、审核与分级(强烈建议)
- 人工审核:标题、封面、敏感度;支持批量上/下架
- 自动规则:黑词、指纹/哈希库比对、AI 识别(仅作辅助)
- 分级:未成年人拦截(年龄校验/AVS),时段/区域限制
- 举报与回溯:保留日志,接入“通知—删除”流程(DMCA/投诉处理)
九、合规与版权(关键)
- 年龄校验:成人内容必须做可靠的年龄验证与访问控制
- 版权来源可追溯:上传者授权/合同留存;应对版权投诉要有“下架通道”
- 隐私与数据保护:GDPR(EU)/TTDSG(DE)/Cookie 同意
- 记录保存:访问/操作日志留档,便于审计
- 服务条款/社区规范:明确禁止违法、未成年、侵犯隐私等内容
十、Docker Compose(入门示例)
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
volumes: ["db:/var/lib/postgresql/data"]
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: password123
ports: ["9000:9000", "9001:9001"]
volumes: ["minio:/data"]
redis:
image: redis:7
ports: ["6379:6379"]
# 你的后端与前端容器(示例)
# api: ...
# web: ...
volumes: { db: {}, minio: {} }
提升方向
- 多区域存储 + CDN(带防盗链)
- DRM/加密(FairPlay/Widevine 需商业方案;开源可做 AES-128 HLS)
- 灰度发布/AB 测试
- 搜索与推荐(Elastic + 协同过滤/Embedding)
- 计费与会员(付费观看、下载券、打赏分成)
好嘞,直接给你一套可跑的最小化模板(MVP)方案:后端(NestJS + TypeORM)、转码 Worker(FFmpeg + BullMQ/Redis)、前端(Next.js + hls.js),再带上年龄验证/分级与合规要点。你按下面粘贴文件即可起步。
0. 目标 & 架构
- 上传(直传到 MinIO/S3,后端发预签名 URL)
- 转码(Worker 拉任务,用 FFmpeg 生成 HLS 多码率 + master.m3u8)
- 分发(对象存储直链;或用 Nginx/CDN)
- 播放(Next.js + hls.js)
- 合规(年龄验证、内容分级、举报与下架)
目录结构(单仓三服务):
video-mvp/
├─ docker-compose.yml # Postgres + Redis + MinIO(对象存储)
├─ api/ # NestJS 后端(JTW、预签、视频元信息)
├─ worker/ # 转码 Worker(BullMQ + FFmpeg)
└─ web/ # Next.js 播放页(hls.js)
1) docker-compose(数据库/队列/对象存储)
docker-compose.yml
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: videodb
ports: ["5432:5432"]
volumes: ["db:/var/lib/postgresql/data"]
redis:
image: redis:7
ports: ["6379:6379"]
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: password123
ports: ["9000:9000", "9001:9001"]
volumes: ["minio:/data"]
volumes:
db: {}
minio: {}
启动:
docker compose up -d
首次打开 http://localhost:9001 配置一个 bucket(如 media
),设置 Public 或按需使用预签名 GET。
2) 后端(NestJS + TypeORM)
初始化(在 api/
):
npm init -y
npm i @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
npm i @nestjs/typeorm typeorm pg
npm i @nestjs/jwt passport passport-jwt @nestjs/passport bcrypt
npm i aws-sdk # 用于对接 MinIO 的 S3 兼容接口
npm i bullmq ioredis
npm i class-validator class-transformer
npm i -D typescript ts-node ts-node-dev @types/node
api/tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"moduleResolution": "node",
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"outDir": "dist",
"strict": true
},
"include": ["src/**/*"]
}
api/src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './modules/app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(3001);
console.log('API on http://localhost:3001');
}
bootstrap();
api/src/modules/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Config } from './config';
import { User } from '../orm/user.entity';
import { Video } from '../orm/video.entity';
import { AuthModule } from './auth.module';
import { UploadModule } from './upload.module';
import { VideoModule } from './video.module';
import { QueueModule } from './queue.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: Config.DB_HOST,
port: +Config.DB_PORT,
username: Config.DB_USER,
password: Config.DB_PASS,
database: Config.DB_NAME,
entities: [User, Video],
synchronize: true, // 仅开发环境
}),
AuthModule,
UploadModule,
VideoModule,
QueueModule,
],
})
export class AppModule {}
api/src/modules/config.ts
export const Config = {
DB_HOST: process.env.DB_HOST || 'localhost',
DB_PORT: process.env.DB_PORT || '5432',
DB_USER: process.env.DB_USER || 'postgres',
DB_PASS: process.env.DB_PASS || 'postgres',
DB_NAME: process.env.DB_NAME || 'videodb',
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
S3_ENDPOINT: process.env.S3_ENDPOINT || 'http://localhost:9000',
S3_REGION: process.env.S3_REGION || 'us-east-1',
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY || 'admin',
S3_SECRET_KEY: process.env.S3_SECRET_KEY || 'password123',
S3_BUCKET: process.env.S3_BUCKET || 'media',
JWT_SECRET: process.env.JWT_SECRET || 'devsecret',
};
api/src/orm/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn() id: number;
@Column({ unique: true }) email: string;
@Column() passwordHash: string;
@Column({ default: 'user' }) role: 'user'|'creator'|'moderator'|'admin';
@Column({ default: false }) ageVerified: boolean;
@CreateDateColumn() createdAt: Date;
}
api/src/orm/video.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm';
import { User } from './user.entity';
@Entity('videos')
export class Video {
@PrimaryGeneratedColumn() id: number;
@ManyToOne(() => User, { eager: true }) owner: User;
@Column() title: string;
@Column({ nullable: true }) description: string;
@Column({ default: 'pending' }) status: 'pending'|'processing'|'online'|'rejected';
@Column({ nullable: true }) coverUrl: string;
@Column({ nullable: true }) srcOriginalKey: string;
@Column({ nullable: true }) hlsMasterKey: string; // e.g. myvideo/master.m3u8
@Column({ default: 'G' }) rating: 'G'|'PG-13'|'R18'; // 分级
@CreateDateColumn() createdAt: Date;
}
api/src/modules/auth.module.ts(简化:仅演示登录/注册与 ageVerified)
import { Module, Controller, Post, Body, Get, Req, UseGuards } from '@nestjs/common';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../orm/user.entity';
import { Config } from './config';
import { PassportModule } from '@nestjs/passport';
import { AuthGuard } from '@nestjs/passport';
@Module({
imports: [PassportModule, JwtModule.register({ secret: Config.JWT_SECRET })],
})
export class AuthModule {}
@Controller('auth')
export class AuthController {
constructor(
@InjectRepository(User) private users: Repository<User>,
private jwt: JwtService
){}
@Post('register')
async register(@Body() dto: {email: string; password: string}) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const u = this.users.create({ email: dto.email, passwordHash });
await this.users.save(u);
return { ok: true };
}
@Post('login')
async login(@Body() dto:{email:string; password:string}) {
const u = await this.users.findOne({ where: { email: dto.email } });
if (!u || !(await bcrypt.compare(dto.password, u.passwordHash))) throw new Error('bad creds');
const token = this.jwt.sign({ sub: u.id, role: u.role, ageVerified: u.ageVerified });
return { token };
}
@Post('verify-age') // 假定你有 KYC/AVS 完成后置位
async verifyAge(@Body() dto:{email:string}) {
const u = await this.users.findOne({ where: { email: dto.email } });
if (!u) throw new Error('user not found');
u.ageVerified = true;
await this.users.save(u);
return { ok: true };
}
}
api/src/modules/upload.module.ts(预签直传到 MinIO/S3)
import { Module, Controller, Post, Body } from '@nestjs/common';
import { Config } from './config';
import AWS from 'aws-sdk';
@Module({})
export class UploadModule {}
@Controller('upload')
export class UploadController {
s3 = new AWS.S3({
endpoint: Config.S3_ENDPOINT,
s3ForcePathStyle: true,
accessKeyId: Config.S3_ACCESS_KEY,
secretAccessKey: Config.S3_SECRET_KEY,
region: Config.S3_REGION,
});
@Post('presign')
async presign(@Body() dto:{objectKey:string; contentType?:string}) {
const params = {
Bucket: Config.S3_BUCKET,
Key: dto.objectKey, // e.g. raw/uuid.mp4
Expires: 3600,
ContentType: dto.contentType || 'video/mp4',
};
const uploadUrl = await this.s3.getSignedUrlPromise('putObject', params);
return { uploadUrl, objectKey: dto.objectKey };
}
}
api/src/modules/queue.module.ts(发转码任务)
import { Module, Controller, Post, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Video } from '../orm/video.entity';
import { Repository } from 'typeorm';
import { Config } from './config';
import { Queue } from 'bullmq';
const transcodeQueue = new Queue('transcode', { connection: { url: Config.REDIS_URL } });
@Module({})
export class QueueModule {}
@Controller('videos')
export class VideoController {
constructor(@InjectRepository(Video) private vids: Repository<Video>){}
@Post()
async create(@Body() dto: { title: string; description?: string; srcOriginalKey: string; rating?: 'G'|'PG-13'|'R18' }) {
const v = this.vids.create({ title: dto.title, description: dto.description, srcOriginalKey: dto.srcOriginalKey, status: 'pending', rating: dto.rating || 'G' });
await this.vids.save(v);
await transcodeQueue.add('transcode', { videoId: v.id, srcOriginalKey: dto.srcOriginalKey });
v.status = 'processing';
await this.vids.save(v);
return { id: v.id, status: v.status };
}
}
api/src/modules/video.module.ts(查询播放信息 + R18 访问控制)
import { Module, Controller, Get, Param, Req } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Video } from '../orm/video.entity';
import { Repository } from 'typeorm';
@Module({})
export class VideoModule {}
@Controller('videos')
export class VideoQueryController {
constructor(@InjectRepository(Video) private vids: Repository<Video>){}
@Get(':id')
async get(@Param('id') id: number, @Req() req:any) {
const v = await this.vids.findOne({ where: { id } });
if (!v) return { error: 'not found' };
// 简化:若是 R18,要求 header 带 ageVerified=true
const ageOk = req.headers['x-age-verified'] === 'true';
if (v.rating === 'R18' && !ageOk) {
return { error: 'age_verification_required' };
}
return {
id: v.id,
title: v.title,
description: v.description,
status: v.status,
coverUrl: v.coverUrl,
hlsMasterUrl: v.hlsMasterKey ? this.publicUrl(v.hlsMasterKey) : null,
rating: v.rating,
};
}
publicUrl(key: string) {
// 假设 MinIO 桶对 GET 可公开
return `http://localhost:9000/media/${key}`;
}
}
运行 API:
# 在 api/ 目录
DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASS=postgres DB_NAME=videodb \
S3_ENDPOINT=http://localhost:9000 S3_ACCESS_KEY=admin S3_SECRET_KEY=password123 S3_BUCKET=media \
REDIS_URL=redis://localhost:6379 \
JWT_SECRET=devsecret \
npx ts-node-dev src/main.ts
3) Worker(转码 + HLS)
在 worker/
:
npm init -y
npm i bullmq ioredis aws-sdk
npm i -D ts-node typescript @types/node
worker/tsconfig.json
{ "compilerOptions": { "module": "commonjs", "target": "es2020", "outDir": "dist", "strict": true }, "include": ["src/**/*"] }
worker/src/index.ts
import { Worker } from 'bullmq';
import { execFile } from 'child_process';
import { promisify } from 'util';
import AWS from 'aws-sdk';
const pexec = promisify(execFile);
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const S3_ENDPOINT = process.env.S3_ENDPOINT || 'http://localhost:9000';
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || 'admin';
const S3_SECRET_KEY = process.env.S3_SECRET_KEY || 'password123';
const S3_BUCKET = process.env.S3_BUCKET || 'media';
const s3 = new AWS.S3({
endpoint: S3_ENDPOINT,
s3ForcePathStyle: true,
accessKeyId: S3_ACCESS_KEY,
secretAccessKey: S3_SECRET_KEY,
region: 'us-east-1',
});
async function downloadTo(path: string, key: string) {
const obj = await s3.getObject({ Bucket: S3_BUCKET, Key: key }).promise();
const fs = await import('fs');
await fs.promises.writeFile(path, obj.Body as Buffer);
}
async function uploadDir(prefix: string, localDir: string) {
const fs = await import('fs');
const path = await import('path');
const files = await fs.promises.readdir(localDir);
for (const f of files) {
const full = path.join(localDir, f);
const stat = await fs.promises.stat(full);
if (stat.isDirectory()) {
await uploadDir(`${prefix}/${f}`, full);
} else {
const Body = await fs.promises.readFile(full);
await s3.putObject({ Bucket: S3_BUCKET, Key: `${prefix}/${f}`, Body, ContentType: f.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' }).promise();
}
}
}
async function transcode(inputPath: string, outDir: string) {
const fs = await import('fs'); const path = await import('path');
await fs.promises.mkdir(`${outDir}/360p`, { recursive: true });
await fs.promises.mkdir(`${outDir}/720p`, { recursive: true });
await pexec('ffmpeg', ['-y','-i', inputPath, '-vf','scale=-2:360','-c:v','h264','-b:v','800k','-c:a','aac','-b:a','96k','-hls_time','6','-hls_playlist_type','vod','-f','hls', `${outDir}/360p/playlist.m3u8`]);
await pexec('ffmpeg', ['-y','-i', inputPath, '-vf','scale=-2:720','-c:v','h264','-b:v','2500k','-c:a','aac','-b:a','128k','-hls_time','6','-hls_playlist_type','vod','-f','hls', `${outDir}/720p/playlist.m3u8`]);
const master = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360
360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
720p/playlist.m3u8
`;
await fs.promises.writeFile(`${outDir}/master.m3u8`, master);
}
new Worker('transcode', async job => {
const { videoId, srcOriginalKey } = job.data as { videoId:number; srcOriginalKey:string };
console.log('Transcoding job', job.id, videoId, srcOriginalKey);
const os = await import('os'); const path = await import('path'); const fs = await import('fs');
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'trans-'));
const inPath = path.join(tmp, 'input.mp4');
await downloadTo(inPath, srcOriginalKey);
const outDir = path.join(tmp, 'out');
await fs.promises.mkdir(outDir);
await transcode(inPath, outDir);
const outPrefix = srcOriginalKey.replace(/^raw\//, 'hls/').replace(/\.mp4$/,'');
await uploadDir(outPrefix, outDir);
console.log('Uploaded HLS ->', outPrefix + '/master.m3u8');
// TODO:回写 DB(这里简化:实际需连 Postgres 更新 videos.hlsMasterKey = `${outPrefix}/master.m3u8`, status='online')
}, { connection: { url: REDIS_URL }});
console.log('Worker running. Needs ffmpeg installed and DB update logic.');
运行 Worker(需本机已装 ffmpeg):
REDIS_URL=redis://localhost:6379 \
S3_ENDPOINT=http://localhost:9000 S3_ACCESS_KEY=admin S3_SECRET_KEY=password123 S3_BUCKET=media \
npx ts-node src/index.ts
注:为简洁我把“更新 DB 状态”的代码留成 TODO。你可以在 Worker 里用
pg
或 TypeORM 直连 Postgres,把videos.hlsMasterKey
更新成hls/.../master.m3u8
并置status='online'
。
4) 前端(Next.js + hls.js 播放)
在 web/
:
npm init -y
npm i next react react-dom
npm i hls.js
web/package.json(最简)
{
"scripts": { "dev": "next dev -p 3000" }
}
web/pages/index.tsx
import Link from 'next/link';
export default function Home() {
return (
<div style={{padding:20}}>
<h1>Video MVP</h1>
<p><Link href="/video/1">播放示例(ID=1)</Link></p>
</div>
);
}
web/pages/video/[id].tsx
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
import { useRouter } from 'next/router';
export default function VideoPage() {
const router = useRouter();
const { id } = router.query;
const [meta, setMeta] = useState<any>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!id) return;
(async () => {
const res = await fetch(`http://localhost:3001/videos/${id}`, {
headers: { 'x-age-verified': 'true' } // 演示:若 R18 需传 true
});
const data = await res.json();
setMeta(data);
if (data.hlsMasterUrl && videoRef.current) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(data.hlsMasterUrl);
hls.attachMedia(videoRef.current);
} else {
videoRef.current.src = data.hlsMasterUrl; // Safari
}
}
})();
}, [id]);
return (
<div style={{padding:20}}>
<h2>{meta?.title || `Video ${id}`}</h2>
{meta?.hlsMasterUrl ? (
<video ref={videoRef} controls style={{width:'100%', maxWidth:900}} />
) : <p>视频转码中或未找到</p>}
{meta?.error && <p style={{color:'red'}}>错误:{meta.error}</p>}
</div>
);
}
启动:
npm run dev
# 打开 http://localhost:3000
5) 测试流程(端到端)
- 在 MinIO Console 建桶
media
。 - 调预签 URL:
curl -X POST http://localhost:3001/upload/presign \
-H "Content-Type: application/json" \
-d '{"objectKey":"raw/demo.mp4","contentType":"video/mp4"}'
得到 uploadUrl
后,PUT 上传文件:
curl -X PUT "<uploadUrl返回的长地址>" \
-H "Content-Type: video/mp4" \
--data-binary @/path/to/local/demo.mp4
- 提交视频元信息(触发转码):
curl -X POST http://localhost:3001/videos \
-H "Content-Type: application/json" \
-d '{"title":"我的第一个视频","srcOriginalKey":"raw/demo.mp4","rating":"PG-13"}'
返回 { id, status:"processing" }
。Worker 转码完成后会把 hls/.../master.m3u8
上传至 MinIO。
你需要补上 Worker 回写 DB 逻辑,使
videos.hlsMasterKey
与status='online'
生效。
- 前端打开
http://localhost:3000/video/<id>
即可播放。
6) 可选:HLS AES-128 简易加密(开源级别)
生成密钥(演示):
openssl rand 16 > enc.key
python3 - <<'PY'
base='http://localhost:9000/media/keys'
print(f'#EXTM3U\nenc.key') # 也可配置完整 URL
PY > enc.keyinfo
FFmpeg(切片 + 加密):
ffmpeg -y -i input.mp4 \
-hls_time 6 -hls_playlist_type vod \
-hls_key_info_file enc.keyinfo \
-f hls out/360p/playlist.m3u8
把 enc.key
放在受控路径(需鉴权或换成预签名 GET)。注意:这只是基础 AES-128,非 DRM。
7) 年龄验证/分级 & 合规
- 分级字段:
videos.rating
(G/PG-13/R18) - 访问门槛:后端校验
ageVerified
,或前端访问需要x-age-verified: true
(演示用),实际应:- 接入 AVS/KYC 服务(身份证/银行卡/信用机构年龄校验),写入
users.ageVerified=true
- R18 路由/接口统一拦截 & 日志审计
- 接入 AVS/KYC 服务(身份证/银行卡/信用机构年龄校验),写入
- 监管要点(强烈建议):
- 版权:仅允许上传者拥有授权的内容;提供“通知—删除”通道
- 未成年人防护:年龄门槛、时间段限制、家长监护建议
- 内容审核:人工 + 规则(黑词/哈希指纹),争议内容先下线复核
- 数据保护:GDPR/隐私政策/日志留存/删除权流程
- 服务条款:禁止违法、隐私侵犯、未成年人相关内容
8) 生产化增强清单
- CDN + 防盗链、Referer 校验、签名 URL
- 断点续传/分块直传(tus/S3 MPU)
- 转码集群(多 Worker + 观察队列)
- 监控(Prometheus + Grafana、S3/Redis/DB 健康)
- 搜索/推荐(Elastic、Embedding + 协同过滤)
- 计费/会员(试看、包月、代币/打赏)
- Nginx/Traefik 作网关,启用 HTTPS/HTTP3
发表回复