我能给你一套中立、合规的「在线视频站(含成人分区)」搭建思路与最小可运行方案(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%' }} />;
}

七、转码任务流(最小闭环)

  1. 用户上传完成 → 后端创建 videos(pending)
  2. 生成转码任务(写入队列/Redis Stream)
  3. Worker 拉取任务:从对象存储下载源文件 → FFmpeg 生成 HLS 多码率 → 上传切片与 master.m3u8 → 更新 videos(online)
  4. 清理源文件(可选)、生成封面 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) 测试流程(端到端)

  1. 在 MinIO Console 建桶 media
  2. 调预签 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
  1. 提交视频元信息(触发转码):
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.hlsMasterKeystatus='online' 生效。

  1. 前端打开 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 路由/接口统一拦截 & 日志审计
  • 监管要点(强烈建议):
    • 版权:仅允许上传者拥有授权的内容;提供“通知—删除”通道
    • 未成年人防护:年龄门槛、时间段限制、家长监护建议
    • 内容审核:人工 + 规则(黑词/哈希指纹),争议内容先下线复核
    • 数据保护:GDPR/隐私政策/日志留存/删除权流程
    • 服务条款:禁止违法、隐私侵犯、未成年人相关内容

8) 生产化增强清单

  • CDN + 防盗链、Referer 校验、签名 URL
  • 断点续传/分块直传(tus/S3 MPU)
  • 转码集群(多 Worker + 观察队列)
  • 监控(Prometheus + Grafana、S3/Redis/DB 健康)
  • 搜索/推荐(Elastic、Embedding + 协同过滤)
  • 计费/会员(试看、包月、代币/打赏)
  • Nginx/Traefik 作网关,启用 HTTPS/HTTP3