Compare commits

...

9 Commits

Author SHA1 Message Date
61a00286fb refactor(history): 重构播放历史模块,统一命名并迁移代码
将原playback-history模块重命名为history模块,统一所有相关文件的命名空间,移除冗余的重复代码,更新全局模块引用和测试用例接口路径
2026-07-02 17:28:57 +08:00
79aeac7f37 feat(playback-history): 添加播放历史模块功能
实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息
2026-07-02 17:28:51 +08:00
fca247c3f2 feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能
1.  为后台剧集列表添加标题和推荐状态筛选参数
2.  新增前台推荐剧集列表接口和前置搜索接口
3.  添加可选布尔值解析工具方法
4.  新增对应接口的端到端测试用例
2026-07-02 17:26:08 +08:00
f2b1043cbc refactor: 统一代码缩进与引号风格,新增CORS配置
1.  修正所有文件的缩进为4空格,将单引号统一改为双引号
2.  新增CORS_ORIGIN环境变量配置并从env读取CORS允许源
3.  新增prettier配置文件统一代码格式
4.  为main.ts添加启动日志输出
2026-07-02 16:03:42 +08:00
0eeeea0c4d feat: 新增剧集封面、集数管理与上传优化功能
1. 新增Cover实体类并注册到数据库模块
2. 为剧集添加总集数字段及自动创建集数槽功能
3. 新增管理员更新剧集集数接口
4. 优化发布剧集列表:支持标题过滤与默认分页大小10
5. 扩展MediaUploadJob实体字段,完善媒体上传流程
6. 重构订单与媒体服务,新增数据库持久化支持
7. 新增剧集标题过滤的端到端测试用例
2026-07-02 15:46:29 +08:00
f342312b92 refactor: 重构项目支持TypeORM数据库持久化
新增数据库实体类与服务实现,将内存存储扩展为支持数据库的完整实现,包括:
1. 新增Drama和Episode实体类,添加多个业务字段
2. 重构DramasService实现数据库CRUD与业务逻辑
3. 升级相关服务类为异步方法适配数据库操作
4. 更新测试用例适配异步接口变更
5. 完善分页、数据转换等辅助工具方法
2026-07-01 21:49:36 +08:00
d3083bf16b feat: 新增数据库持久化支持并完善后台管理功能
- 引入dotenv加载环境变量,新增数据库同步配置项
- 为用户服务、管理员服务、数据服务添加数据库持久化逻辑
- 新增管理员用户邮箱字段,完善权限校验逻辑
- 添加数据库初始化与种子数据脚本
- 新增e2e测试验证管理员数据持久化
2026-07-01 17:11:29 +08:00
026665b2cc feat: 添加数据分析模块、媒体上传功能与剧集管理完善
本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
2026-07-01 16:36:06 +08:00
5fa8c5e0a9 feat(dramas,media): add video resolution fields and fix quote style
1. 为剧集剧集添加width和height字段,支持存储视频分辨率信息
2. 统一将media.controller.ts中的单引号改为双引号,保持代码风格一致
2026-07-01 16:04:49 +08:00
58 changed files with 4429 additions and 522 deletions

8
.env
View File

@@ -1,5 +1,8 @@
PORT=3030 PORT=3030
JWT_SECRET=change-me JWT_SECRET=change-me
ADMIN_JWT_SECRET=change-me-admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# MySQL settings for the production TypeORM layer. # MySQL settings for the production TypeORM layer.
# The current scaffold defines entities and API boundaries; repository persistence can be enabled when the database is ready. # The current scaffold defines entities and API boundaries; repository persistence can be enabled when the database is ready.
@@ -8,3 +11,8 @@ DB_PORT=22246
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=zhxiao1124.. DB_PASSWORD=zhxiao1124..
DB_DATABASE=onion_tiktok DB_DATABASE=onion_tiktok
# CORS settings
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
DB_SYNCHRONIZE = true

View File

@@ -11,3 +11,8 @@ DB_PORT=3306
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=
DB_DATABASE=cth_tk_backend DB_DATABASE=cth_tk_backend
# CORS settings
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
DB_SYNCHRONIZE = true

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"tabWidth": 4,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 390,
"eslintIntegration": true,
"bracketSpacing": true,
"stylelintIntegration": true
}

View File

@@ -23,6 +23,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"dotenv": "17.4.1",
"mysql2": "^3.0.0", "mysql2": "^3.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",

3
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
class-validator: class-validator:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.4 version: 0.14.4
dotenv:
specifier: 17.4.1
version: 17.4.1
mysql2: mysql2:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.22.5(@types/node@24.13.2) version: 3.22.5(@types/node@24.13.2)

View File

@@ -1,27 +1,33 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from "@nestjs/config";
import { AdminModule } from './modules/admin/admin.module'; import { AdminModule } from "./modules/admin/admin.module";
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from "./modules/auth/auth.module";
import { DramasModule } from './modules/dramas/dramas.module'; import { DataModule } from "./modules/data/data.module";
import { HealthModule } from './modules/health/health.module'; import { DatabaseModule } from "./modules/database/database.module";
import { LogsModule } from './modules/logs/logs.module'; import { DramasModule } from "./modules/dramas/dramas.module";
import { MediaModule } from './modules/media/media.module'; import { HealthModule } from "./modules/health/health.module";
import { OrdersModule } from './modules/orders/orders.module'; import { LogsModule } from "./modules/logs/logs.module";
import { PlayerModule } from './modules/player/player.module'; import { MediaModule } from "./modules/media/media.module";
import { OrdersModule } from "./modules/orders/orders.module";
import { PlayerModule } from "./modules/player/player.module";
import { HistoryModule } from "./modules/history/history.module";
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
}), }),
DatabaseModule,
AdminModule, AdminModule,
HealthModule, HealthModule,
LogsModule, LogsModule,
DramasModule, DramasModule,
DataModule,
MediaModule, MediaModule,
AuthModule, AuthModule,
OrdersModule, OrdersModule,
PlayerModule, PlayerModule,
HistoryModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -9,10 +9,7 @@ import { LogsService } from "./modules/logs/logs.service";
export function bootstrapApp(app: INestApplication) { export function bootstrapApp(app: INestApplication) {
app.enableCors({ app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',') ?? [ origin: process.env.CORS_ORIGIN?.split(","),
'http://localhost:5173',
'http://127.0.0.1:5173',
],
credentials: true, credentials: true,
}); });
app.use(RequestIdMiddleware); app.use(RequestIdMiddleware);
@@ -25,18 +22,13 @@ export function bootstrapApp(app: INestApplication) {
); );
const logsService = app.get(LogsService); const logsService = app.get(LogsService);
app.useGlobalFilters(new HttpExceptionFilter(logsService));
app.useGlobalInterceptors(
new RequestLoggingInterceptor(logsService, app.get(Reflector)),
new ResponseInterceptor(),
);
const config = new DocumentBuilder() app.useGlobalFilters(new HttpExceptionFilter(logsService));
.setTitle("TikTok Short Drama Backend") app.useGlobalInterceptors(new RequestLoggingInterceptor(logsService, app.get(Reflector)), new ResponseInterceptor());
.setDescription("RESTful API for TikTok short drama backend services.")
.setVersion("1.0") const config = new DocumentBuilder().setTitle("TikTok Short Drama Backend").setDescription("RESTful API for TikTok short drama backend services.").setVersion("1.0").addBearerAuth().build();
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/api/docs", app, document); SwaggerModule.setup("/api/docs", app, document);
} }

View File

@@ -1,11 +1,13 @@
import { NestFactory } from '@nestjs/core'; import "dotenv/config";
import { AppModule } from './app.module'; import { NestFactory } from "@nestjs/core";
import { bootstrapApp } from './bootstrap'; import { AppModule } from "./app.module";
import { bootstrapApp } from "./bootstrap";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
bootstrapApp(app); bootstrapApp(app);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
console.log(`Application is running on: ${await app.getUrl()}`);
} }
void bootstrap(); void bootstrap();

View File

@@ -2,17 +2,24 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
OnModuleInit,
Optional,
UnauthorizedException, UnauthorizedException,
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectDataSource } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { AdminLoginDto } from './dto/admin-login.dto'; import { AdminLoginDto } from './dto/admin-login.dto';
import { CreateAdminUserDto } from './dto/create-admin-user.dto'; import { CreateAdminUserDto } from './dto/create-admin-user.dto';
import { CreatePermissionDto } from './dto/create-permission.dto'; import { CreatePermissionDto } from './dto/create-permission.dto';
import { CreateRoleDto } from './dto/create-role.dto'; import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto'; import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
import { AdminUser } from './entities/admin-user.entity';
import { Permission } from './entities/permission.entity';
import { Role } from './entities/role.entity';
import { import {
AdminUserRecord, AdminUserRecord,
AdminUserView, AdminUserView,
@@ -26,7 +33,7 @@ interface PaginationInput {
} }
@Injectable() @Injectable()
export class AdminService { export class AdminService implements OnModuleInit {
private permissionSequence = 1; private permissionSequence = 1;
private roleSequence = 1; private roleSequence = 1;
private adminSequence = 1; private adminSequence = 1;
@@ -34,8 +41,22 @@ export class AdminService {
private readonly roles: RoleRecord[] = []; private readonly roles: RoleRecord[] = [];
private readonly admins: AdminUserRecord[] = []; private readonly admins: AdminUserRecord[] = [];
constructor(private readonly jwtService: JwtService) { constructor(
this.seed(); private readonly jwtService: JwtService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {
if (!this.hasDatabase()) {
this.seedMemory();
}
}
async onModuleInit() {
if (this.hasDatabase()) {
await this.seedDatabase();
await this.loadDatabaseRecords();
}
} }
async login(dto: AdminLoginDto) { async login(dto: AdminLoginDto) {
@@ -58,7 +79,17 @@ export class AdminService {
}; };
} }
createPermission(dto: CreatePermissionDto): PermissionRecord { async createPermission(dto: CreatePermissionDto): Promise<PermissionRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Permission);
if (await repository.findOne({ where: { code: dto.code } })) {
throw new ConflictException('Permission code already exists');
}
const saved = await repository.save(repository.create(dto));
await this.loadDatabaseRecords();
return this.toPermissionRecord(saved);
}
if (this.permissions.some((item) => item.code === dto.code)) { if (this.permissions.some((item) => item.code === dto.code)) {
throw new ConflictException('Permission code already exists'); throw new ConflictException('Permission code already exists');
} }
@@ -77,13 +108,34 @@ export class AdminService {
return permission; return permission;
} }
listPermissions( async listPermissions(
pagination: PaginationInput, pagination: PaginationInput,
): PaginatedResponse<PermissionRecord> { ): Promise<PaginatedResponse<PermissionRecord>> {
if (this.hasDatabase()) {
await this.loadDatabaseRecords();
}
return this.paginate([...this.permissions], pagination); return this.paginate([...this.permissions], pagination);
} }
createRole(dto: CreateRoleDto): RoleRecord { async createRole(dto: CreateRoleDto): Promise<RoleRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Role);
if (await repository.findOne({ where: { code: dto.code } })) {
throw new ConflictException('Role code already exists');
}
const saved = await repository.save(
repository.create({
code: dto.code,
name: dto.name,
description: dto.description,
permissionIds: dto.permissionIds ?? [],
}),
);
await this.loadDatabaseRecords();
return this.roles.find((role) => role.id === Number(saved.id)) as RoleRecord;
}
if (this.roles.some((item) => item.code === dto.code)) { if (this.roles.some((item) => item.code === dto.code)) {
throw new ConflictException('Role code already exists'); throw new ConflictException('Role code already exists');
} }
@@ -107,11 +159,27 @@ export class AdminService {
return role; return role;
} }
listRoles(pagination: PaginationInput): PaginatedResponse<RoleRecord> { async listRoles(pagination: PaginationInput): Promise<PaginatedResponse<RoleRecord>> {
if (this.hasDatabase()) {
await this.loadDatabaseRecords();
}
return this.paginate([...this.roles], pagination); return this.paginate([...this.roles], pagination);
} }
updateRolePermissions(id: number, permissionIds: number[]): RoleRecord { async updateRolePermissions(id: number, permissionIds: number[]): Promise<RoleRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Role);
const role = await repository.findOne({ where: { id } });
if (!role) {
throw new NotFoundException('Role not found');
}
role.permissionIds = [...new Set(permissionIds)];
await repository.save(role);
await this.loadDatabaseRecords();
return this.roles.find((item) => item.id === id) as RoleRecord;
}
const role = this.roles.find((item) => item.id === id); const role = this.roles.find((item) => item.id === id);
if (!role) { if (!role) {
throw new NotFoundException('Role not found'); throw new NotFoundException('Role not found');
@@ -126,7 +194,29 @@ export class AdminService {
return role; return role;
} }
createAdminUser(dto: CreateAdminUserDto): AdminUserView { async createAdminUser(dto: CreateAdminUserDto): Promise<AdminUserView> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(AdminUser);
if (await repository.findOne({ where: { username: dto.username } })) {
throw new ConflictException('Admin username already exists');
}
const saved = await repository.save(
repository.create({
username: dto.username,
passwordHash: bcrypt.hashSync(dto.password, 10),
nickname: dto.nickname,
email: dto.email,
status: 'active',
isSuperAdmin: false,
roleIds: dto.roleIds ?? [],
}),
);
await this.loadDatabaseRecords();
return this.toAdminView(
this.admins.find((admin) => admin.id === Number(saved.id)) as AdminUserRecord,
);
}
if (this.admins.some((item) => item.username === dto.username)) { if (this.admins.some((item) => item.username === dto.username)) {
throw new ConflictException('Admin username already exists'); throw new ConflictException('Admin username already exists');
} }
@@ -149,19 +239,35 @@ export class AdminService {
return this.toAdminView(admin); return this.toAdminView(admin);
} }
listAdminUsers( async listAdminUsers(
pagination: PaginationInput, pagination: PaginationInput,
): PaginatedResponse<AdminUserView> { ): Promise<PaginatedResponse<AdminUserView>> {
if (this.hasDatabase()) {
await this.loadDatabaseRecords();
}
return this.paginate( return this.paginate(
this.admins.map((admin) => this.toAdminView(admin)), this.admins.map((admin) => this.toAdminView(admin)),
pagination, pagination,
); );
} }
updateAdminStatus( async updateAdminStatus(
id: number, id: number,
status: 'active' | 'disabled', status: 'active' | 'disabled',
): AdminUserView { ): Promise<AdminUserView> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(AdminUser);
const admin = await repository.findOne({ where: { id } });
if (!admin) {
throw new NotFoundException('Admin user not found');
}
admin.status = status;
await repository.save(admin);
await this.loadDatabaseRecords();
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
}
const admin = this.findAdminById(id); const admin = this.findAdminById(id);
if (!admin) { if (!admin) {
throw new NotFoundException('Admin user not found'); throw new NotFoundException('Admin user not found');
@@ -172,7 +278,19 @@ export class AdminService {
return this.toAdminView(admin); return this.toAdminView(admin);
} }
updateAdminRoles(id: number, roleIds: number[]): AdminUserView { async updateAdminRoles(id: number, roleIds: number[]): Promise<AdminUserView> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(AdminUser);
const admin = await repository.findOne({ where: { id } });
if (!admin) {
throw new NotFoundException('Admin user not found');
}
admin.roleIds = [...new Set(roleIds)];
await repository.save(admin);
await this.loadDatabaseRecords();
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
}
const admin = this.findAdminById(id); const admin = this.findAdminById(id);
if (!admin) { if (!admin) {
throw new NotFoundException('Admin user not found'); throw new NotFoundException('Admin user not found');
@@ -183,10 +301,23 @@ export class AdminService {
return this.toAdminView(admin); return this.toAdminView(admin);
} }
updateProfile( async updateProfile(
adminId: number, adminId: number,
dto: UpdateAdminProfileDto, dto: UpdateAdminProfileDto,
): AdminUserView { ): Promise<AdminUserView> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(AdminUser);
const admin = await repository.findOne({ where: { id: adminId } });
if (!admin) {
throw new NotFoundException('Admin user not found');
}
admin.nickname = dto.nickname;
admin.email = dto.email;
await repository.save(admin);
await this.loadDatabaseRecords();
return this.toAdminView(this.findAdminById(adminId) as AdminUserRecord);
}
const admin = this.findAdminById(adminId); const admin = this.findAdminById(adminId);
if (!admin) { if (!admin) {
throw new NotFoundException('Admin user not found'); throw new NotFoundException('Admin user not found');
@@ -198,11 +329,26 @@ export class AdminService {
return this.toAdminView(admin); return this.toAdminView(admin);
} }
changePassword( async changePassword(
adminId: number, adminId: number,
oldPassword: string, oldPassword: string,
newPassword: string, newPassword: string,
): Record<string, never> { ): Promise<Record<string, never>> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(AdminUser);
const admin = await repository.findOne({ where: { id: adminId } });
if (!admin) {
throw new NotFoundException('Admin user not found');
}
if (!bcrypt.compareSync(oldPassword, admin.passwordHash)) {
throw new UnprocessableEntityException('Old password is incorrect');
}
admin.passwordHash = bcrypt.hashSync(newPassword, 10);
await repository.save(admin);
await this.loadDatabaseRecords();
return {};
}
const admin = this.findAdminById(adminId); const admin = this.findAdminById(adminId);
if (!admin) { if (!admin) {
throw new NotFoundException('Admin user not found'); throw new NotFoundException('Admin user not found');
@@ -256,7 +402,7 @@ export class AdminService {
}; };
} }
private seed() { private seedMemory() {
const codes = [ const codes = [
['permission:read', '查看权限'], ['permission:read', '查看权限'],
['permission:create', '创建权限'], ['permission:create', '创建权限'],
@@ -275,6 +421,8 @@ export class AdminService {
['episode:update', '更新剧集'], ['episode:update', '更新剧集'],
['media:read', '查看媒体'], ['media:read', '查看媒体'],
['media:create', '创建媒体'], ['media:create', '创建媒体'],
['data:read', '查看数据'],
['data:sync', '同步数据'],
['payment:read', '查看支付记录'], ['payment:read', '查看支付记录'],
['log:read', '查看日志'], ['log:read', '查看日志'],
]; ];
@@ -298,6 +446,141 @@ export class AdminService {
}); });
} }
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private async seedDatabase() {
const dataSource = this.dataSource as DataSource;
const permissionRepository = dataSource.getRepository(Permission);
for (const [code, name] of this.permissionSeeds()) {
const existing = await permissionRepository.findOne({ where: { code } });
if (!existing) {
await permissionRepository.save(
permissionRepository.create({ code, name }),
);
} else if (existing.name !== name) {
existing.name = name;
await permissionRepository.save(existing);
}
}
const adminRepository = dataSource.getRepository(AdminUser);
const username = process.env.ADMIN_USERNAME ?? 'admin';
const existingAdmin = await adminRepository.findOne({ where: { username } });
if (!existingAdmin) {
await adminRepository.save(
adminRepository.create({
username,
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
nickname: 'Super Admin',
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
status: 'active',
isSuperAdmin: true,
roleIds: [],
}),
);
}
}
private async loadDatabaseRecords() {
const dataSource = this.dataSource as DataSource;
const permissions = await dataSource.getRepository(Permission).find({
order: { id: 'ASC' },
});
this.permissions.splice(
0,
this.permissions.length,
...permissions.map((permission) => this.toPermissionRecord(permission)),
);
const roles = await dataSource.getRepository(Role).find({
order: { id: 'ASC' },
});
this.roles.splice(
0,
this.roles.length,
...roles.map((role) => this.toRoleRecord(role)),
);
const admins = await dataSource.getRepository(AdminUser).find({
order: { id: 'ASC' },
});
this.admins.splice(
0,
this.admins.length,
...admins.map((admin) => this.toAdminRecord(admin)),
);
}
private permissionSeeds() {
return [
['permission:read', '查看权限'],
['permission:create', '创建权限'],
['role:read', '查看角色'],
['role:create', '创建角色'],
['role:update', '更新角色'],
['admin-user:read', '查看人员'],
['admin-user:create', '创建人员'],
['admin-user:update', '更新人员'],
['user:read', '查看用户'],
['user:update', '更新用户'],
['drama:read', '查看短剧'],
['drama:create', '创建短剧'],
['drama:update', '更新短剧'],
['episode:create', '创建剧集'],
['episode:update', '更新剧集'],
['media:read', '查看媒体'],
['media:create', '创建媒体'],
['data:read', '查看数据'],
['data:sync', '同步数据'],
['payment:read', '查看支付记录'],
['log:read', '查看日志'],
] as const;
}
private toPermissionRecord(permission: Permission): PermissionRecord {
return {
id: Number(permission.id),
code: permission.code,
name: permission.name,
description: permission.description,
createdAt: permission.createdAt?.toISOString?.() ?? new Date().toISOString(),
updatedAt: permission.updatedAt?.toISOString?.() ?? new Date().toISOString(),
};
}
private toRoleRecord(role: Role): RoleRecord {
const permissionIds = role.permissionIds ?? [];
return {
id: Number(role.id),
code: role.code,
name: role.name,
description: role.description,
permissionIds,
permissions: this.permissions.filter((permission) =>
permissionIds.includes(permission.id),
),
createdAt: role.createdAt?.toISOString?.() ?? new Date().toISOString(),
updatedAt: role.updatedAt?.toISOString?.() ?? new Date().toISOString(),
};
}
private toAdminRecord(admin: AdminUser): AdminUserRecord {
return {
id: Number(admin.id),
username: admin.username,
passwordHash: admin.passwordHash,
nickname: admin.nickname,
email: admin.email,
status: admin.status as 'active' | 'disabled',
isSuperAdmin: admin.isSuperAdmin,
roleIds: admin.roleIds ?? [],
createdAt: admin.createdAt?.toISOString?.() ?? new Date().toISOString(),
updatedAt: admin.updatedAt?.toISOString?.() ?? new Date().toISOString(),
};
}
private paginate<T>( private paginate<T>(
records: T[], records: T[],
input: PaginationInput, input: PaginationInput,

View File

@@ -22,6 +22,9 @@ export class AdminUser {
@Column({ type: 'varchar', length: 128, nullable: true }) @Column({ type: 'varchar', length: 128, nullable: true })
nickname?: string; nickname?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email?: string;
@Column({ type: 'varchar', length: 32, default: 'active' }) @Column({ type: 'varchar', length: 32, default: 'active' })
status: string; status: string;

View File

@@ -38,7 +38,7 @@ export class AdminJwtAuthGuard implements CanActivate {
throw new UnauthorizedException('Admin not authenticated'); throw new UnauthorizedException('Admin not authenticated');
} }
const adminUser = this.adminService.findAdminById(payload.sub); const adminUser = await this.adminService.findAdminById(payload.sub);
if (!adminUser || adminUser.status !== 'active') { if (!adminUser || adminUser.status !== 'active') {
throw new UnauthorizedException('Admin not authenticated'); throw new UnauthorizedException('Admin not authenticated');
} }

View File

@@ -14,7 +14,7 @@ export class PermissionsGuard implements CanActivate {
private readonly adminService: AdminService, private readonly adminService: AdminService,
) {} ) {}
canActivate(context: ExecutionContext): boolean { async canActivate(context: ExecutionContext): Promise<boolean> {
const required = const required =
this.reflector.getAllAndOverride<string[]>( this.reflector.getAllAndOverride<string[]>(
REQUIRED_PERMISSIONS_METADATA, REQUIRED_PERMISSIONS_METADATA,
@@ -38,7 +38,9 @@ export class PermissionsGuard implements CanActivate {
return true; return true;
} }
const permissions = this.adminService.getPermissionCodesForAdmin(adminUser.id); const permissions = await this.adminService.getPermissionCodesForAdmin(
adminUser.id,
);
const allowed = required.every((permission) => const allowed = required.every((permission) =>
permissions.includes(permission), permissions.includes(permission),
); );

View File

@@ -12,7 +12,7 @@ export class AuthService {
async loginWithTikTok(dto: TikTokLoginDto) { async loginWithTikTok(dto: TikTokLoginDto) {
const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code); const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code);
const result = this.usersService.upsertTikTokUser({ const result = await this.usersService.upsertTikTokUser({
tiktokOpenId, tiktokOpenId,
tiktokUnionId: dto.tiktokUnionId, tiktokUnionId: dto.tiktokUnionId,
nickname: dto.nickname, nickname: dto.nickname,

View File

@@ -28,7 +28,7 @@ export class JwtAuthGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync<{ sub: number }>(token); const payload = await this.jwtService.verifyAsync<{ sub: number }>(token);
const user = this.usersService.findById(payload.sub); const user = await this.usersService.findById(payload.sub);
if (!user || user.status !== 'active') { if (!user || user.status !== 'active') {
throw new UnauthorizedException('Not authenticated'); throw new UnauthorizedException('Not authenticated');
} }

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '../admin/guards/permissions.guard';
import { DataService } from './data.service';
@ApiTags('Data')
@Controller('/api/admin/v1/data')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
export class DataController {
constructor(private readonly dataService: DataService) {}
@Post('/sync')
@RequirePermissions('data:sync')
sync(@Body() body: { dramaId?: number }) {
return this.dataService.sync(body);
}
@Get('/overview')
@RequirePermissions('data:read')
getOverview() {
return this.dataService.getOverview();
}
@Get('/dramas')
@RequirePermissions('data:read')
listDramaMetrics(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listDramaMetrics({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/episodes')
@RequirePermissions('data:read')
listEpisodeMetrics(
@Query('dramaId') dramaId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listEpisodeMetrics({
dramaId: dramaId ? Number(dramaId) : undefined,
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/sync-jobs')
@RequirePermissions('data:read')
listSyncJobs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listSyncJobs({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { DataController } from './data.controller';
import { DataService } from './data.service';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
@Module({
imports: [AdminModule, DramasModule],
controllers: [DataController],
providers: [DataService, TikTokDataProvider],
})
export class DataModule {}

View File

@@ -0,0 +1,312 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { DataSyncJob } from './entities/data-sync-job.entity';
import { DramaMetric } from './entities/drama-metric.entity';
import { EpisodeMetric } from './entities/episode-metric.entity';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
interface PaginationInput {
page: number;
pageSize: number;
}
interface SyncInput {
dramaId?: number;
}
export interface DramaMetricRecord {
dramaId: number;
title: string;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface EpisodeMetricRecord {
dramaId: number;
episodeId: number;
title: string;
episodeNo: number;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface SyncJobRecord {
id: number;
jobId: string;
status: 'completed' | 'failed';
dramaId?: number;
dramaCount: number;
episodeCount: number;
createdAt: string;
}
@Injectable()
export class DataService {
private syncJobSequence = 1;
private readonly dramaMetrics = new Map<number, DramaMetricRecord>();
private readonly episodeMetrics = new Map<number, EpisodeMetricRecord>();
private readonly syncJobs: SyncJobRecord[] = [];
constructor(
private readonly dramasService: DramasService,
private readonly tiktokDataProvider: TikTokDataProvider,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async sync(input: SyncInput = {}) {
const now = new Date().toISOString();
const dramas = (await this.dramasService.listAllDramas()).filter(
(drama) => !input.dramaId || drama.id === input.dramaId,
);
const dramaIds = new Set(dramas.map((drama) => drama.id));
const episodes = (await this.dramasService.listAllEpisodes()).filter(
(episode) => dramaIds.has(episode.dramaId),
);
const dramaRecords = dramas.map((drama) => ({
dramaId: drama.id,
title: drama.title,
...this.tiktokDataProvider.getDramaMetrics(drama),
syncedAt: now,
}));
const episodeRecords = episodes.map((episode) => ({
dramaId: episode.dramaId,
episodeId: episode.id,
title: episode.title,
episodeNo: episode.episodeNo,
...this.tiktokDataProvider.getEpisodeMetrics(episode),
syncedAt: now,
}));
for (const record of dramaRecords) {
this.dramaMetrics.set(record.dramaId, record);
}
for (const record of episodeRecords) {
this.episodeMetrics.set(record.episodeId, record);
}
const job: SyncJobRecord = {
id: this.syncJobSequence++,
jobId: `DATA_SYNC_${Date.now()}_${this.syncJobSequence}`,
status: 'completed',
dramaId: input.dramaId,
dramaCount: dramas.length,
episodeCount: episodes.length,
createdAt: now,
};
this.syncJobs.unshift(job);
if (this.hasDatabase()) {
const dramaMetricRepository = this.dataSource.getRepository(DramaMetric);
const episodeMetricRepository = this.dataSource.getRepository(EpisodeMetric);
const syncJobRepository = this.dataSource.getRepository(DataSyncJob);
for (const record of dramaRecords) {
const existing = await dramaMetricRepository.findOne({
where: { dramaId: record.dramaId },
});
await dramaMetricRepository.save(
dramaMetricRepository.create({
...existing,
...record,
syncedAt: new Date(record.syncedAt),
}),
);
}
for (const record of episodeRecords) {
const existing = await episodeMetricRepository.findOne({
where: { episodeId: record.episodeId },
});
await episodeMetricRepository.save(
episodeMetricRepository.create({
...existing,
...record,
syncedAt: new Date(record.syncedAt),
}),
);
}
await syncJobRepository.save(
syncJobRepository.create({
...job,
createdAt: new Date(job.createdAt),
}),
);
}
return job;
}
async getOverview() {
const dramaRecords = this.hasDatabase()
? (await this.dataSource.getRepository(DramaMetric).find()).map((record) =>
this.toDramaMetricRecord(record),
)
: Array.from(this.dramaMetrics.values());
const episodeRecords = this.hasDatabase()
? (await this.dataSource.getRepository(EpisodeMetric).find()).map((record) =>
this.toEpisodeMetricRecord(record),
)
: Array.from(this.episodeMetrics.values());
const latestJob = this.hasDatabase()
? (
await this.dataSource.getRepository(DataSyncJob).find({
order: { id: 'DESC' },
take: 1,
})
)[0]
: undefined;
return {
totalDramas: dramaRecords.length,
totalEpisodes: episodeRecords.length,
totalPlays: episodeRecords.reduce((sum, item) => sum + item.plays, 0),
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
latestSyncedAt: latestJob?.createdAt?.toISOString?.() ?? this.syncJobs[0]?.createdAt,
};
}
async listDramaMetrics(input: PaginationInput): Promise<PaginatedResponse<DramaMetricRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(DramaMetric);
const [records, total] = await repository.findAndCount({
order: { dramaId: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toDramaMetricRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate(Array.from(this.dramaMetrics.values()), input);
}
async listEpisodeMetrics(
input: PaginationInput & { dramaId?: number },
): Promise<PaginatedResponse<EpisodeMetricRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(EpisodeMetric);
const [records, total] = await repository.findAndCount({
where: input.dramaId ? { dramaId: input.dramaId } : {},
order: { episodeNo: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toEpisodeMetricRecord(record)),
pagination: { page, pageSize, total },
};
}
const records = Array.from(this.episodeMetrics.values()).filter(
(item) => !input.dramaId || item.dramaId === input.dramaId,
);
return this.paginate(records, input);
}
async listSyncJobs(input: PaginationInput): Promise<PaginatedResponse<SyncJobRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(DataSyncJob);
const [records, total] = await repository.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toSyncJobRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate(this.syncJobs, input);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toDramaMetricRecord(record: DramaMetric): DramaMetricRecord {
return {
dramaId: Number(record.dramaId),
title: record.title,
plays: Number(record.plays),
likes: Number(record.likes),
shares: Number(record.shares),
comments: Number(record.comments),
completionRate: record.completionRate,
averageWatchSeconds: record.averageWatchSeconds,
syncedAt: record.syncedAt.toISOString(),
};
}
private toEpisodeMetricRecord(record: EpisodeMetric): EpisodeMetricRecord {
return {
dramaId: Number(record.dramaId),
episodeId: Number(record.episodeId),
title: record.title,
episodeNo: record.episodeNo,
plays: Number(record.plays),
likes: Number(record.likes),
shares: Number(record.shares),
comments: Number(record.comments),
completionRate: record.completionRate,
averageWatchSeconds: record.averageWatchSeconds,
syncedAt: record.syncedAt.toISOString(),
};
}
private toSyncJobRecord(record: DataSyncJob): SyncJobRecord {
return {
id: Number(record.id),
jobId: record.jobId,
status: record.status as 'completed' | 'failed',
dramaId: record.dramaId ? Number(record.dramaId) : undefined,
dramaCount: record.dramaCount,
episodeCount: record.episodeCount,
createdAt: record.createdAt.toISOString(),
};
}
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
return {
list: records.slice(start, start + pageSize),
pagination: {
page,
pageSize,
total: records.length,
},
};
}
}

View File

@@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('data_sync_jobs')
export class DataSyncJob {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 128 })
jobId: string;
@Column({ type: 'varchar', length: 32 })
status: string;
@Column({ type: 'bigint', nullable: true })
dramaId?: number;
@Column({ type: 'int', default: 0 })
dramaCount: number;
@Column({ type: 'int', default: 0 })
episodeCount: number;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('drama_metrics')
export class DramaMetric {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('episode_metrics')
export class EpisodeMetric {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column({ type: 'bigint' })
dramaId: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'int' })
episodeNo: number;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { DramaRecord, EpisodeRecord } from '../../dramas/interfaces/drama-records.interface';
@Injectable()
export class TikTokDataProvider {
getDramaMetrics(drama: DramaRecord) {
const base = drama.id * 1000 + drama.totalEpisodes * 100;
return {
plays: base + 500,
likes: base + 80,
shares: drama.id * 13 + 20,
comments: drama.id * 7 + 12,
completionRate: 0.68,
averageWatchSeconds: 96,
};
}
getEpisodeMetrics(episode: EpisodeRecord) {
const base = episode.id * 500 + episode.episodeNo * 50;
return {
plays: base + 300,
likes: base + 40,
shares: episode.id * 5 + 8,
comments: episode.id * 3 + 6,
completionRate: 0.72,
averageWatchSeconds: Math.min(episode.durationSeconds || 180, 120),
};
}
}

View File

@@ -0,0 +1,55 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminUser } from '../admin/entities/admin-user.entity';
import { Permission } from '../admin/entities/permission.entity';
import { Role } from '../admin/entities/role.entity';
import { DataSyncJob } from '../data/entities/data-sync-job.entity';
import { DramaMetric } from '../data/entities/drama-metric.entity';
import { EpisodeMetric } from '../data/entities/episode-metric.entity';
import { Drama } from '../dramas/entities/drama.entity';
import { Episode } from '../dramas/entities/episode.entity';
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
import { RequestLog } from '../logs/entities/request-log.entity';
import { Cover } from '../media/entities/cover.entity';
import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
import { Order } from '../orders/entities/order.entity';
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
import { UserPlaybackHistory } from '../history/entities/user-playback-history.entity';
import { User } from '../users/entities/user.entity';
const databaseImports = process.env.DB_HOST
? [
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_DATABASE ?? 'cth_tk',
entities: [
AdminUser,
Permission,
Role,
DataSyncJob,
DramaMetric,
EpisodeMetric,
Drama,
Episode,
PlaybackErrorLog,
RequestLog,
Cover,
MediaUploadJob,
Order,
UserEpisodeUnlock,
UserPlaybackHistory,
User,
],
synchronize: process.env.DB_SYNCHRONIZE === 'true',
}),
]
: [];
@Module({
imports: databaseImports,
})
export class DatabaseModule {}

View File

@@ -1,87 +1,162 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator'; import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard'; import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from '../admin/guards/permissions.guard'; import { PermissionsGuard } from "../admin/guards/permissions.guard";
import { CreateDramaDto } from './dto/create-drama.dto'; import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
import { CreateEpisodeDto } from './dto/create-episode.dto'; import { CreateDramaDto } from "./dto/create-drama.dto";
import { DramasService } from './dramas.service'; import { CreateEpisodeDto } from "./dto/create-episode.dto";
import { PublishStatus } from './drama-status.enum'; import { UpdateDramaDto } from "./dto/update-drama.dto";
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
import { DramasService } from "./dramas.service";
import { PublishStatus } from "./drama-status.enum";
@ApiTags('Dramas') @ApiTags("Dramas")
@Controller() @Controller()
export class DramasController { export class DramasController {
constructor(private readonly dramasService: DramasService) {} constructor(private readonly dramasService: DramasService) {}
@Post('/api/admin/v1/dramas') @Post("/api/admin/v1/dramas")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('drama:create') @RequirePermissions("drama:create")
createDrama(@Body() dto: CreateDramaDto) { createDrama(@Body() dto: CreateDramaDto) {
return this.dramasService.createDrama(dto); return this.dramasService.createDrama(dto);
} }
@Get('/api/admin/v1/dramas') @Get("/api/admin/v1/dramas")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('drama:read') @RequirePermissions("drama:read")
listAdminDramas(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
return this.dramasService.listAdminDramas({ return this.dramasService.listAdminDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 20,
title,
isRecommended: this.parseOptionalBoolean(isRecommended),
});
}
@Get("/api/admin/v1/dramas/:id")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:read")
getDrama(@Param("id") id: string) {
return this.dramasService.getDramaOrThrow(Number(id));
}
@Patch("/api/admin/v1/dramas/:id")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:update")
updateDrama(@Param("id") id: string, @Body() dto: UpdateDramaDto) {
return this.dramasService.updateDrama(Number(id), dto);
}
@Post("/api/admin/v1/dramas/:id/episodes/batch")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("episode:create")
configureEpisodes(@Param("id") id: string, @Body() dto: ConfigureEpisodesDto) {
return this.dramasService.configureEpisodes(Number(id), dto);
}
@Get("/api/admin/v1/dramas/:id/episodes")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("episode:update")
listAdminEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listAdminEpisodes(Number(id), {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
}); });
} }
@Post('/api/admin/v1/episodes') @Post("/api/admin/v1/episodes")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:create') @RequirePermissions("episode:create")
createEpisode(@Body() dto: CreateEpisodeDto) { createEpisode(@Body() dto: CreateEpisodeDto) {
return this.dramasService.createEpisode(dto); return this.dramasService.createEpisode(dto);
} }
@Post('/api/admin/v1/dramas/:id/submit-review') @Patch("/api/admin/v1/episodes/:id")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('drama:update') @RequirePermissions("episode:update")
submitReview(@Param('id') id: string) { updateEpisode(@Param("id") id: string, @Body() dto: UpdateEpisodeDto) {
return this.dramasService.updateEpisode(Number(id), dto);
}
@Patch("/api/admin/v1/dramas/:dramaId/episodes/:episodeId")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("episode:update")
updateDramaEpisode(@Param("dramaId") dramaId: string, @Param("episodeId") episodeId: string, @Body() dto: UpdateEpisodeDto) {
return this.dramasService.updateDramaEpisode(Number(dramaId), Number(episodeId), dto);
}
@Post("/api/admin/v1/dramas/:id/submit-review")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:update")
submitReview(@Param("id") id: string) {
return this.dramasService.submitReview(Number(id)); return this.dramasService.submitReview(Number(id));
} }
@Patch('/api/admin/v1/episodes/:id/publish-status') @Patch("/api/admin/v1/episodes/:id/publish-status")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:update') @RequirePermissions("episode:update")
updatePublishStatus( updatePublishStatus(@Param("id") id: string, @Body() dto: { publishStatus: PublishStatus }) {
@Param('id') id: string, return this.dramasService.updateEpisodePublishStatus(Number(id), dto.publishStatus);
@Body() dto: { publishStatus: PublishStatus },
) {
return this.dramasService.updateEpisodePublishStatus(
Number(id),
dto.publishStatus,
);
} }
@Get('/api/app/v1/dramas') @Get("/api/app/v1/dramas")
listPublishedDramas( listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dramasService.listPublishedDramas({ return this.dramasService.listPublishedDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 10,
}); });
} }
@Get('/api/app/v1/dramas/:id/episodes') @Get("/api/app/v1/dramas/recommended")
listPublishedEpisodes( listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
@Param('id') id: string, return this.dramasService.listRecommendedDramas({
@Query('page') page?: string, page: Number(page) || 1,
@Query('pageSize') pageSize?: string, pageSize: Number(pageSize) || 10,
) { });
}
@Get("/api/app/v1/dramas/presearch")
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchVideos({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
keyword,
});
}
@Get("/api/app/v1/dramas/:id/episodes")
listPublishedEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listPublishedEpisodes(Number(id), { return this.dramasService.listPublishedEpisodes(Number(id), {
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 20,
}); });
} }
@Get("/api/app/v1/dramas/:id")
getPublishedDrama(@Param("id") id: string) {
return this.dramasService.getPublishedDramaOrThrow(Number(id));
}
private parseOptionalBoolean(value?: string) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return undefined;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Max, Min } from 'class-validator';
export class ConfigureEpisodesDto {
@ApiProperty()
@IsInt()
@Min(1)
@Max(500)
episodeCount: number;
}

View File

@@ -7,6 +7,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { PublishStatus } from '../drama-status.enum'; import { PublishStatus } from '../drama-status.enum';
@@ -88,6 +89,13 @@ export class CreateDramaDto {
@IsBoolean() @IsBoolean()
isRecommended?: boolean; isRecommended?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
totalEpisodes?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()

View File

@@ -82,6 +82,18 @@ export class CreateEpisodeDto {
@Min(1) @Min(1)
durationSeconds: number; durationSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
width?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
height?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -119,10 +131,12 @@ export class CreateEpisodeDto {
@IsEnum(PublishStatus) @IsEnum(PublishStatus)
status?: PublishStatus; status?: PublishStatus;
@ApiPropertyOptional({ enum: ['pending', 'approved', 'rejected'] }) @ApiPropertyOptional({
enum: ['not_submitted', 'pending', 'reviewing', 'approved', 'rejected'],
})
@IsOptional() @IsOptional()
@IsString() @IsString()
reviewStatus?: 'pending' | 'approved' | 'rejected'; reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
@ApiPropertyOptional({ enum: PublishStatus }) @ApiPropertyOptional({ enum: PublishStatus })
@IsOptional() @IsOptional()

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDramaDto } from './create-drama.dto';
export class UpdateDramaDto extends PartialType(CreateDramaDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateEpisodeDto } from './create-episode.dto';
export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {}

View File

@@ -12,12 +12,18 @@ export class Drama {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Column({ type: 'varchar', length: 128, nullable: true })
tiktokAlbumId?: string;
@Column({ type: 'varchar', length: 255 }) @Column({ type: 'varchar', length: 255 })
title: string; title: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description?: string; description?: string;
@Column({ type: 'int', nullable: true })
coverId?: number;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024 })
coverUrl: string; coverUrl: string;
@@ -51,6 +57,9 @@ export class Drama {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isRecommended: boolean; isRecommended: boolean;
@Column({ type: 'int', default: 1 })
onlineVersion: number;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@@ -17,6 +17,9 @@ export class Episode {
@Column({ type: 'bigint' }) @Column({ type: 'bigint' })
dramaId: number; dramaId: number;
@Column({ type: 'varchar', length: 128, nullable: true })
tiktokEpisodeId?: string;
@Column({ type: 'int' }) @Column({ type: 'int' })
episodeNo: number; episodeNo: number;
@@ -26,24 +29,48 @@ export class Episode {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description?: string; description?: string;
@Column({ type: 'int', nullable: true })
coverId?: number;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024 })
coverUrl: string; coverUrl: string;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024, nullable: true })
videoUrl: string; videoUrl?: string;
@Column({ type: 'varchar', length: 128, nullable: true })
byteplusVid?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
storageBucket?: string;
@Column({ type: 'varchar', length: 512, nullable: true })
objectKey?: string;
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
trialDurationSeconds?: number; trialDurationSeconds?: number;
@Column({ type: 'int' }) @Column({ type: 'int', default: 0 })
durationSeconds: number; durationSeconds: number;
@Column({ type: 'int', nullable: true })
width?: number;
@Column({ type: 'int', nullable: true })
height?: number;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isPaid: boolean; isPaid: boolean;
@Column({ type: 'varchar', length: 16, default: 'free' })
freeType: 'free' | 'paid';
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
unlockPrice?: number; unlockPrice?: number;
@Column({ type: 'int', default: 0 })
price: number;
@Column({ type: 'datetime', nullable: true }) @Column({ type: 'datetime', nullable: true })
publishAt?: Date; publishAt?: Date;
@@ -53,6 +80,15 @@ export class Episode {
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft }) @Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
status: PublishStatus; status: PublishStatus;
@Column({ type: 'varchar', length: 32, default: 'not_submitted' })
reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
@Column({ type: 'varchar', length: 512, nullable: true })
reviewMessage?: string;
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
publishStatus: PublishStatus;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@@ -18,6 +18,8 @@ export interface DramaRecord {
sortOrder: number; sortOrder: number;
isRecommended: boolean; isRecommended: boolean;
onlineVersion: number; onlineVersion: number;
totalEpisodes: number;
publishedEpisodes: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -35,8 +37,12 @@ export interface EpisodeRecord {
coverId?: number; coverId?: number;
coverUrl: string; coverUrl: string;
videoUrl?: string; videoUrl?: string;
storageBucket?: string;
objectKey?: string;
trialDurationSeconds?: number; trialDurationSeconds?: number;
durationSeconds: number; durationSeconds: number;
width?: number;
height?: number;
isPaid: boolean; isPaid: boolean;
freeType: 'free' | 'paid'; freeType: 'free' | 'paid';
unlockPrice?: number; unlockPrice?: number;
@@ -44,7 +50,8 @@ export interface EpisodeRecord {
publishAt?: string; publishAt?: string;
nextReleaseAt?: string; nextReleaseAt?: string;
status: PublishStatus; status: PublishStatus;
reviewStatus: 'pending' | 'approved' | 'rejected'; reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
reviewMessage?: string;
publishStatus: PublishStatus; publishStatus: PublishStatus;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpsertPlaybackHistoryDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsInt()
@Min(0)
progressSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
completed?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
occurredAt?: string;
}

View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_playback_histories')
@Index(['userId', 'episodeId'], { unique: true })
@Index(['userId', 'lastPlayedAt'])
export class UserPlaybackHistory {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint' })
userId: number;
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'int', default: 0 })
progressSeconds: number;
@Column({ type: 'int', default: 0 })
durationSeconds: number;
@Column({ type: 'boolean', default: false })
completed: boolean;
@Column({ type: 'datetime' })
lastPlayedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { HistoryService } from './history.service';
@ApiTags('History')
@Controller('/api/app/v1/history')
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
@Post()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
upsertHistory(
@CurrentUser() user: UserRecord,
@Body() dto: UpsertPlaybackHistoryDto,
) {
return this.historyService.upsertHistory(user, dto);
}
@Get()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
listHistory(
@CurrentUser() user: UserRecord,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.historyService.listHistory(user, {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module';
import { UsersModule } from '../users/users.module';
import { HistoryController } from './history.controller';
import { HistoryService } from './history.service';
@Module({
imports: [AuthModule, DramasModule, UsersModule],
controllers: [HistoryController],
providers: [HistoryService],
})
export class HistoryModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import {
DramaRecord,
EpisodeRecord,
} from '../dramas/interfaces/drama-records.interface';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
import {
PlaybackHistoryListItem,
PlaybackHistoryRecord,
} from './interfaces/history-record.interface';
interface PaginationInput {
page: number;
pageSize: number;
}
@Injectable()
export class HistoryService {
private historySequence = 1;
private readonly histories: PlaybackHistoryRecord[] = [];
constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async upsertHistory(
user: UserRecord,
dto: UpsertPlaybackHistoryDto,
): Promise<PlaybackHistoryRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
const progressSeconds = this.resolveProgress(
dto.progressSeconds,
episode.durationSeconds,
);
const completed =
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
const lastPlayedAt = dto.occurredAt
? new Date(dto.occurredAt)
: new Date();
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const existing = await repository.findOne({
where: { userId: user.id, episodeId: episode.id },
});
const entity =
existing ??
repository.create({
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
});
entity.dramaId = episode.dramaId;
entity.progressSeconds = progressSeconds;
entity.durationSeconds = episode.durationSeconds;
entity.completed = completed;
entity.lastPlayedAt = lastPlayedAt;
return this.toRecord(await repository.save(entity));
}
const now = new Date().toISOString();
const existing = this.histories.find(
(item) => item.userId === user.id && item.episodeId === episode.id,
);
if (existing) {
existing.dramaId = episode.dramaId;
existing.progressSeconds = progressSeconds;
existing.durationSeconds = episode.durationSeconds;
existing.completed = completed;
existing.lastPlayedAt = lastPlayedAt.toISOString();
existing.updatedAt = now;
return existing;
}
const record: PlaybackHistoryRecord = {
id: this.historySequence++,
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
progressSeconds,
durationSeconds: episode.durationSeconds,
completed,
lastPlayedAt: lastPlayedAt.toISOString(),
createdAt: now,
updatedAt: now,
};
this.histories.push(record);
return record;
}
async listHistory(
user: UserRecord,
pagination: PaginationInput,
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const [records, total] = await repository.findAndCount({
where: { userId: user.id },
order: { lastPlayedAt: 'DESC', id: 'DESC' },
skip,
take,
});
const list = await Promise.all(
records.map((record) => this.toListItem(this.toRecord(record))),
);
return { list, pagination: { page, pageSize, total } };
}
const page = this.paginate(
this.histories
.filter((item) => item.userId === user.id)
.sort((left, right) => {
const timeDiff =
new Date(right.lastPlayedAt).getTime() -
new Date(left.lastPlayedAt).getTime();
return timeDiff || right.id - left.id;
}),
pagination,
);
return {
...page,
list: await Promise.all(
page.list.map((record) => this.toListItem(record)),
),
};
}
private async toListItem(
record: PlaybackHistoryRecord,
): Promise<PlaybackHistoryListItem> {
const [drama, episode] = await Promise.all([
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
]);
return this.mergeListItem(record, drama, episode);
}
private mergeListItem(
record: PlaybackHistoryRecord,
drama: DramaRecord,
episode: EpisodeRecord,
): PlaybackHistoryListItem {
return {
id: record.id,
dramaId: record.dramaId,
episodeId: record.episodeId,
episodeNo: episode.episodeNo,
title: episode.title,
coverUrl: episode.coverUrl,
progressSeconds: record.progressSeconds,
durationSeconds: record.durationSeconds,
completed: record.completed,
lastPlayedAt: record.lastPlayedAt,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private resolveProgress(progressSeconds: number, durationSeconds: number) {
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
return {
id: Number(entity.id),
userId: Number(entity.userId),
dramaId: Number(entity.dramaId),
episodeId: Number(entity.episodeId),
progressSeconds: entity.progressSeconds,
durationSeconds: entity.durationSeconds,
completed: entity.completed,
lastPlayedAt: this.toIso(entity.lastPlayedAt),
createdAt: this.toIso(entity.createdAt),
updatedAt: this.toIso(entity.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private resolvePagination(input: PaginationInput) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const { page, pageSize, skip, take } = this.resolvePagination(input);
return {
list: records.slice(skip, skip + take),
pagination: { page, pageSize, total: records.length },
};
}
}

View File

@@ -0,0 +1,27 @@
export interface PlaybackHistoryRecord {
id: number;
userId: number;
dramaId: number;
episodeId: number;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
createdAt: string;
updatedAt: string;
}
export interface PlaybackHistoryListItem {
id: number;
dramaId: number;
episodeId: number;
episodeNo: number;
title: string;
coverUrl: string;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
dramaTitle: string;
dramaCoverUrl: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class CompleteDirectUploadTaskDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
providerUploadId?: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, Min } from 'class-validator';
export class CreateDirectUploadTaskDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsString()
fileName: string;
@ApiProperty()
@IsString()
contentType: string;
@ApiProperty()
@IsInt()
@Min(1)
fileSize: number;
}

View File

@@ -0,0 +1,21 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('covers')
export class Cover {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 255 })
fileName: string;
@Column({ type: 'varchar', length: 1024 })
url: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -16,6 +16,9 @@ export class MediaUploadJob {
@Column({ type: 'varchar', length: 128 }) @Column({ type: 'varchar', length: 128 })
jobId: string; jobId: string;
@Column({ type: 'int', nullable: true })
episodeId?: number;
@Index() @Index()
@Column({ type: 'varchar', length: 128, nullable: true }) @Column({ type: 'varchar', length: 128, nullable: true })
byteplusVid?: string; byteplusVid?: string;
@@ -23,6 +26,42 @@ export class MediaUploadJob {
@Column({ type: 'varchar', length: 64 }) @Column({ type: 'varchar', length: 64 })
status: string; status: string;
@Column({ type: 'varchar', length: 255, nullable: true })
fileName?: string;
@Column({ type: 'varchar', length: 128, nullable: true })
contentType?: string;
@Column({ type: 'int', nullable: true })
fileSize?: number;
@Column({ type: 'varchar', length: 255, nullable: true })
bucket?: string;
@Column({ type: 'varchar', length: 512, nullable: true })
objectKey?: string;
@Column({ type: 'varchar', length: 1024, nullable: true })
uploadUrl?: string;
@Column({ type: 'varchar', length: 1024, nullable: true })
videoUrl?: string;
@Column({ type: 'int', nullable: true })
width?: number;
@Column({ type: 'int', nullable: true })
height?: number;
@Column({ type: 'int', nullable: true })
durationSeconds?: number;
@Column({ type: 'varchar', length: 32, nullable: true })
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
@Column({ type: 'varchar', length: 512, nullable: true })
reviewMessage?: string;
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
rawResponse?: Record<string, unknown>; rawResponse?: Record<string, unknown>;

View File

@@ -8,8 +8,21 @@ export interface CoverRecord {
export interface MediaUploadJobRecord { export interface MediaUploadJobRecord {
id: number; id: number;
jobId: string; jobId: string;
episodeId?: number;
byteplusVid?: string; byteplusVid?: string;
status: string; status: string;
fileName?: string;
contentType?: string;
fileSize?: number;
bucket?: string;
objectKey?: string;
uploadUrl?: string;
videoUrl?: string;
width?: number;
height?: number;
durationSeconds?: number;
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
reviewMessage?: string;
rawResponse?: Record<string, unknown>; rawResponse?: Record<string, unknown>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -1,37 +1,65 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator'; import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard'; import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from '../admin/guards/permissions.guard'; import { PermissionsGuard } from "../admin/guards/permissions.guard";
import { CreateCoverDto } from './dto/create-cover.dto'; import { CreateCoverDto } from "./dto/create-cover.dto";
import { CreateUploadJobDto } from './dto/create-upload-job.dto'; import { CreateDirectUploadTaskDto } from "./dto/create-direct-upload-task.dto";
import { MediaService } from './media.service'; import { CreateUploadJobDto } from "./dto/create-upload-job.dto";
import { MediaService } from "./media.service";
@ApiTags('Media') @ApiTags("Media")
@Controller('/api/admin/v1/media') @Controller("/api/admin/v1/media")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
export class MediaController { export class MediaController {
constructor(private readonly mediaService: MediaService) {} constructor(private readonly mediaService: MediaService) {}
@Post('/covers') @Post("/covers")
@RequirePermissions('media:create') @RequirePermissions("media:create")
createCover(@Body() dto: CreateCoverDto) { createCover(@Body() dto: CreateCoverDto) {
return this.mediaService.createCover(dto); return this.mediaService.createCover(dto);
} }
@Post('/upload-jobs') @Post("/upload-jobs")
@RequirePermissions('media:create') @RequirePermissions("media:create")
createUploadJob(@Body() dto: CreateUploadJobDto) { createUploadJob(@Body() dto: CreateUploadJobDto) {
return this.mediaService.createUploadJob(dto); return this.mediaService.createUploadJob(dto);
} }
@Get('/upload-jobs') @Post("/upload-tasks")
@RequirePermissions('media:read') @RequirePermissions("media:create")
listUploadJobs( createDirectUploadTask(@Body() dto: CreateDirectUploadTaskDto) {
@Query('page') page?: string, return this.mediaService.createDirectUploadTask(dto);
@Query('pageSize') pageSize?: string, }
) {
@Get("/upload-tasks/:jobId")
@RequirePermissions("media:read")
getUploadTask(@Param("jobId") jobId: string) {
return this.mediaService.getUploadTask(jobId);
}
@Patch("/upload-tasks/:jobId/complete")
@RequirePermissions("media:create")
completeDirectUploadTask(@Param("jobId") jobId: string) {
return this.mediaService.completeDirectUploadTask(jobId);
}
@Post("/upload-tasks/:jobId/review/retry")
@RequirePermissions("media:create")
retryReview(@Param("jobId") jobId: string) {
return this.mediaService.retryReview(jobId);
}
@Post("/review-status/sync")
@RequirePermissions("media:create")
syncReviewStatuses() {
return this.mediaService.syncReviewStatuses();
}
@Get("/upload-jobs")
@RequirePermissions("media:read")
listUploadJobs(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.mediaService.listUploadJobs({ return this.mediaService.listUploadJobs({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 20,

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { MediaController } from './media.controller'; import { MediaController } from './media.controller';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
@Module({ @Module({
imports: [AdminModule], imports: [AdminModule, DramasModule],
controllers: [MediaController], controllers: [MediaController],
providers: [MediaService], providers: [MediaService, BytePlusMediaProvider],
exports: [MediaService], exports: [MediaService],
}) })
export class MediaModule {} export class MediaModule {}

View File

@@ -1,11 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { CreateCoverDto } from './dto/create-cover.dto'; import { CreateCoverDto } from './dto/create-cover.dto';
import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto';
import { CreateUploadJobDto } from './dto/create-upload-job.dto'; import { CreateUploadJobDto } from './dto/create-upload-job.dto';
import { Cover } from './entities/cover.entity';
import { MediaUploadJob } from './entities/media-upload-job.entity';
import { import {
CoverRecord, CoverRecord,
MediaUploadJobRecord, MediaUploadJobRecord,
} from './interfaces/media-records.interface'; } from './interfaces/media-records.interface';
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
interface PaginationInput { interface PaginationInput {
page: number; page: number;
@@ -19,7 +27,23 @@ export class MediaService {
private readonly covers: CoverRecord[] = []; private readonly covers: CoverRecord[] = [];
private readonly uploadJobs: MediaUploadJobRecord[] = []; private readonly uploadJobs: MediaUploadJobRecord[] = [];
createCover(dto: CreateCoverDto): CoverRecord { constructor(
private readonly dramasService: DramasService,
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async createCover(dto: CreateCoverDto): Promise<CoverRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Cover);
const saved = await repository.save(
repository.create({ fileName: dto.fileName, url: dto.url }),
);
return this.toCoverRecord(saved);
}
const cover: CoverRecord = { const cover: CoverRecord = {
id: this.coverSequence++, id: this.coverSequence++,
fileName: dto.fileName, fileName: dto.fileName,
@@ -30,7 +54,20 @@ export class MediaService {
return cover; return cover;
} }
createUploadJob(dto: CreateUploadJobDto): MediaUploadJobRecord { async createUploadJob(dto: CreateUploadJobDto): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const saved = await repository.save(
repository.create({
jobId: dto.jobId,
byteplusVid: dto.byteplusVid,
status: dto.status,
rawResponse: dto.rawResponse,
}),
);
return this.toJobRecord(saved);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const job: MediaUploadJobRecord = { const job: MediaUploadJobRecord = {
id: this.uploadJobSequence++, id: this.uploadJobSequence++,
@@ -45,21 +82,331 @@ export class MediaService {
return job; return job;
} }
listUploadJobs( async createDirectUploadTask(
input: PaginationInput, dto: CreateDirectUploadTaskDto,
): PaginatedResponse<MediaUploadJobRecord> { ): Promise<MediaUploadJobRecord> {
const page = Math.max(input.page, 1); await this.dramasService.getEpisodeOrThrow(dto.episodeId);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const jobId = randomUUID();
const start = (page - 1) * pageSize; const upload = this.bytePlusMediaProvider.createDirectUpload({
const records = [...this.uploadJobs].reverse(); jobId,
fileName: dto.fileName,
return { contentType: dto.contentType,
list: records.slice(start, start + pageSize), });
pagination: { const base = {
page, jobId,
pageSize, episodeId: dto.episodeId,
total: records.length, status: 'pending',
fileName: dto.fileName,
contentType: dto.contentType,
fileSize: dto.fileSize,
bucket: upload.bucket,
objectKey: upload.objectKey,
uploadUrl: upload.uploadUrl,
reviewStatus: 'not_submitted' as const,
rawResponse: {
headers: upload.headers,
expiresAt: upload.expiresAt,
}, },
}; };
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
return this.toJobRecord(await repository.save(repository.create(base)));
}
const now = new Date().toISOString();
const job: MediaUploadJobRecord = {
id: this.uploadJobSequence++,
...base,
createdAt: now,
updatedAt: now,
};
this.uploadJobs.push(job);
return job;
}
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const job = await repository.findOne({ where: { jobId } });
if (!job || !job.objectKey || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const completed = this.bytePlusMediaProvider.completeUpload({
jobId: job.jobId,
objectKey: job.objectKey,
});
const review = this.bytePlusMediaProvider.submitReview(
completed.byteplusVid,
);
job.byteplusVid = completed.byteplusVid;
job.videoUrl = completed.videoUrl;
job.width = completed.width;
job.height = completed.height;
job.durationSeconds = completed.durationSeconds;
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
job.rawResponse = {
...(job.rawResponse ?? {}),
upload: completed.rawResponse,
review,
};
await repository.save(job);
await this.dramasService.attachEpisodeMedia({
episodeId: Number(job.episodeId),
byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl,
storageBucket: job.bucket ?? '',
objectKey: job.objectKey,
durationSeconds: completed.durationSeconds,
width: completed.width,
height: completed.height,
reviewStatus: review.reviewStatus,
reviewMessage: review.reviewMessage,
});
return this.toJobRecord(job);
}
const job = this.getMemJobOrThrow(jobId);
if (!job.objectKey || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const completed = this.bytePlusMediaProvider.completeUpload({
jobId: job.jobId,
objectKey: job.objectKey,
});
const review = this.bytePlusMediaProvider.submitReview(completed.byteplusVid);
Object.assign(job, {
byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl,
width: completed.width,
height: completed.height,
durationSeconds: completed.durationSeconds,
status: review.reviewStatus,
reviewStatus: review.reviewStatus,
reviewMessage: review.reviewMessage,
rawResponse: {
...job.rawResponse,
upload: completed.rawResponse,
review,
},
updatedAt: new Date().toISOString(),
});
await this.dramasService.attachEpisodeMedia({
episodeId: job.episodeId,
byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl,
storageBucket: job.bucket ?? '',
objectKey: job.objectKey,
durationSeconds: completed.durationSeconds,
width: completed.width,
height: completed.height,
reviewStatus: review.reviewStatus,
reviewMessage: review.reviewMessage,
});
return job;
}
async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const job = await repository.findOne({ where: { jobId } });
if (!job || !job.byteplusVid || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
await repository.save(job);
await this.dramasService.updateEpisodeReviewStatus(
Number(job.episodeId),
review.reviewStatus,
review.reviewMessage,
);
return this.toJobRecord(job);
}
const job = this.getMemJobOrThrow(jobId);
if (!job.byteplusVid || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
job.updatedAt = new Date().toISOString();
await this.dramasService.updateEpisodeReviewStatus(
job.episodeId,
review.reviewStatus,
review.reviewMessage,
);
return job;
}
async syncReviewStatuses() {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const candidates = await repository.find({
where: [{ reviewStatus: 'reviewing' }, { reviewStatus: 'pending' }],
});
const reviewingJobs = candidates.filter(
(job) => job.byteplusVid && job.episodeId,
);
for (const job of reviewingJobs) {
const review = this.bytePlusMediaProvider.queryReviewStatus(
job.byteplusVid as string,
);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
await repository.save(job);
await this.dramasService.updateEpisodeReviewStatus(
Number(job.episodeId),
review.reviewStatus,
review.reviewMessage,
);
}
return { status: 'completed', synced: reviewingJobs.length };
}
const reviewingJobs = this.uploadJobs.filter(
(job) =>
job.byteplusVid &&
job.episodeId &&
(job.reviewStatus === 'reviewing' || job.reviewStatus === 'pending'),
);
for (const job of reviewingJobs) {
const review = this.bytePlusMediaProvider.queryReviewStatus(
job.byteplusVid as string,
);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
job.updatedAt = new Date().toISOString();
await this.dramasService.updateEpisodeReviewStatus(
job.episodeId as number,
review.reviewStatus,
review.reviewMessage,
);
}
return { status: 'completed', synced: reviewingJobs.length };
}
async getUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
return this.getUploadJobOrThrow(jobId);
}
async listUploadJobs(
input: PaginationInput,
): Promise<PaginatedResponse<MediaUploadJobRecord>> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
if (this.hasDatabase()) {
const [records, total] = await this.dataSource
.getRepository(MediaUploadJob)
.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((job) => this.toJobRecord(job)),
pagination: { page, pageSize, total },
};
}
const records = [...this.uploadJobs].reverse();
return {
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { page, pageSize, total: this.uploadJobs.length },
};
}
private async getUploadJobOrThrow(jobId: string): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const job = await this.dataSource
.getRepository(MediaUploadJob)
.findOne({ where: { jobId } });
if (!job) {
throw new NotFoundException('Upload task not found');
}
return this.toJobRecord(job);
}
return this.getMemJobOrThrow(jobId);
}
private getMemJobOrThrow(jobId: string): MediaUploadJobRecord {
const job = this.uploadJobs.find((item) => item.jobId === jobId);
if (!job) {
throw new NotFoundException('Upload task not found');
}
return job;
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toCoverRecord(cover: Cover): CoverRecord {
return {
id: Number(cover.id),
fileName: cover.fileName,
url: cover.url,
createdAt: this.toIso(cover.createdAt),
};
}
private toJobRecord(job: MediaUploadJob): MediaUploadJobRecord {
return {
id: Number(job.id),
jobId: job.jobId,
episodeId:
job.episodeId === null || job.episodeId === undefined
? undefined
: Number(job.episodeId),
byteplusVid: job.byteplusVid,
status: job.status,
fileName: job.fileName,
contentType: job.contentType,
fileSize: job.fileSize,
bucket: job.bucket,
objectKey: job.objectKey,
uploadUrl: job.uploadUrl,
videoUrl: job.videoUrl,
width: job.width,
height: job.height,
durationSeconds: job.durationSeconds,
reviewStatus: job.reviewStatus,
reviewMessage: job.reviewMessage,
rawResponse: job.rawResponse,
createdAt: this.toIso(job.createdAt),
updatedAt: this.toIso(job.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
} }
} }

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
interface CreateUploadInput {
jobId: string;
fileName: string;
contentType: string;
}
interface CompleteUploadInput {
jobId: string;
objectKey: string;
}
@Injectable()
export class BytePlusMediaProvider {
createDirectUpload(input: CreateUploadInput) {
const bucket = process.env.BYTEPLUS_BUCKET ?? 'mock-short-drama';
const objectKey = `videos/${input.jobId}/${input.fileName}`;
return {
bucket,
objectKey,
uploadUrl: `https://mock-byteplus-upload.local/${bucket}/${objectKey}`,
headers: {
'content-type': input.contentType,
},
expiresAt: Date.now() + 15 * 60 * 1000,
};
}
completeUpload(input: CompleteUploadInput) {
return {
byteplusVid: `mock_vid_${input.jobId.replace(/-/g, '_')}`,
videoUrl: `https://mock-byteplus-cdn.local/${input.objectKey}`,
width: 1080,
height: 1920,
durationSeconds: 180,
rawResponse: {
provider: 'byteplus-mock',
objectKey: input.objectKey,
},
};
}
submitReview(byteplusVid: string) {
return {
reviewStatus: 'reviewing' as const,
reviewMessage: `Mock review submitted for ${byteplusVid}`,
};
}
queryReviewStatus(byteplusVid: string) {
return {
reviewStatus: 'approved' as const,
reviewMessage: `Mock review approved for ${byteplusVid}`,
};
}
}

View File

@@ -1,15 +1,21 @@
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
Optional,
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service'; import { DramasService } from '../dramas/dramas.service';
import { UserRecord } from '../users/interfaces/user-record.interface'; import { UserRecord } from '../users/interfaces/user-record.interface';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto'; import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
import { Order } from './entities/order.entity';
import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity';
import { import {
OrderRecord, OrderRecord,
OrderStatus,
UserEpisodeUnlockRecord, UserEpisodeUnlockRecord,
} from './interfaces/order-records.interface'; } from './interfaces/order-records.interface';
@@ -20,23 +26,49 @@ export class OrdersService {
private readonly orders: OrderRecord[] = []; private readonly orders: OrderRecord[] = [];
private readonly unlocks: UserEpisodeUnlockRecord[] = []; private readonly unlocks: UserEpisodeUnlockRecord[] = [];
constructor(private readonly dramasService: DramasService) {} constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
createOrder(user: UserRecord, dto: CreateOrderDto): OrderRecord { async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
const episode = this.dramasService.getPublishedEpisodeOrThrow(dto.episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
if (!episode.isPaid) { if (!episode.isPaid) {
throw new UnprocessableEntityException('Episode is free'); throw new UnprocessableEntityException('Episode is free');
} }
const now = new Date().toISOString(); const amount = episode.price ?? episode.unlockPrice ?? 0;
const order: OrderRecord = { const orderNo = this.createOrderNo();
id: this.orderSequence++, const tradeOrderId = this.createTradeOrderId();
orderNo: this.createOrderNo(), this.orderSequence++;
tradeOrderId: this.createTradeOrderId(),
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Order);
const entity = repository.create({
orderNo,
tradeOrderId,
userId: user.id, userId: user.id,
albumId: episode.albumId, albumId: episode.albumId,
episodeId: episode.id, episodeId: episode.id,
amount: episode.price ?? episode.unlockPrice ?? 0, amount,
status: 'pending',
});
return this.toOrderRecord(await repository.save(entity));
}
const now = new Date().toISOString();
const order: OrderRecord = {
id: this.orders.length + 1,
orderNo,
tradeOrderId,
userId: user.id,
albumId: episode.albumId,
episodeId: episode.id,
amount,
status: 'pending', status: 'pending',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -46,13 +78,10 @@ export class OrdersService {
return order; return order;
} }
getPlayback(user: UserRecord, episodeId: number) { async getPlayback(user: UserRecord, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const isUnlocked = const isUnlocked =
!episode.isPaid || !episode.isPaid || (await this.isUnlocked(user.id, episodeId));
this.unlocks.some(
(unlock) => unlock.userId === user.id && unlock.episodeId === episodeId,
);
return { return {
episodeId: episode.id, episodeId: episode.id,
@@ -66,7 +95,68 @@ export class OrdersService {
}; };
} }
handlePaymentWebhook(dto: TikTokPaymentWebhookDto) { async handlePaymentWebhook(dto: TikTokPaymentWebhookDto) {
if (this.hasDatabase()) {
const orderRepository = this.dataSource.getRepository(Order);
const where: Record<string, string>[] = [];
if (dto.orderNo) {
where.push({ orderNo: dto.orderNo });
}
if (dto.tradeOrderId) {
where.push({ tradeOrderId: dto.tradeOrderId });
}
const order = where.length
? await orderRepository.findOne({ where })
: null;
if (!order) {
throw new NotFoundException('Order not found');
}
if (order.status === 'paid') {
return {
orderNo: order.orderNo,
status: order.status,
unlocked: await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
),
};
}
order.providerTransactionId = dto.providerTransactionId;
order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId;
order.status = dto.status;
await orderRepository.save(order);
if (
dto.status === 'paid' &&
!(await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
))
) {
const unlockRepository = this.dataSource.getRepository(UserEpisodeUnlock);
await unlockRepository.save(
unlockRepository.create({
userId: Number(order.userId),
episodeId: Number(order.episodeId),
source: 'payment',
orderNo: order.orderNo,
}),
);
}
return {
orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId,
status: order.status,
unlocked: await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
),
};
}
const order = this.orders.find( const order = this.orders.find(
(item) => (item) =>
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId, item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
@@ -79,7 +169,7 @@ export class OrdersService {
return { return {
orderNo: order.orderNo, orderNo: order.orderNo,
status: order.status, status: order.status,
unlocked: this.isUnlocked(order.userId, order.episodeId), unlocked: await this.isUnlocked(order.userId, order.episodeId),
}; };
} }
@@ -88,7 +178,10 @@ export class OrdersService {
order.status = dto.status; order.status = dto.status;
order.updatedAt = new Date().toISOString(); order.updatedAt = new Date().toISOString();
if (dto.status === 'paid' && !this.isUnlocked(order.userId, order.episodeId)) { if (
dto.status === 'paid' &&
!(await this.isUnlocked(order.userId, order.episodeId))
) {
this.unlocks.push({ this.unlocks.push({
id: this.unlockSequence++, id: this.unlockSequence++,
userId: order.userId, userId: order.userId,
@@ -103,40 +196,95 @@ export class OrdersService {
orderNo: order.orderNo, orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId, tradeOrderId: order.tradeOrderId,
status: order.status, status: order.status,
unlocked: this.isUnlocked(order.userId, order.episodeId), unlocked: await this.isUnlocked(order.userId, order.episodeId),
}; };
} }
hasEpisodePermission(userId: number, episodeId: number) { async hasEpisodePermission(userId: number, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
return !episode.isPaid || this.isUnlocked(userId, episodeId); return !episode.isPaid || (await this.isUnlocked(userId, episodeId));
} }
listUnlockRecords(input: { async listUnlockRecords(input: {
page: number; page: number;
pageSize: number; pageSize: number;
}): PaginatedResponse<UserEpisodeUnlockRecord> { }): Promise<PaginatedResponse<UserEpisodeUnlockRecord>> {
const page = Math.max(input.page, 1); const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
const records = [...this.unlocks].reverse();
if (this.hasDatabase()) {
const [records, total] = await this.dataSource
.getRepository(UserEpisodeUnlock)
.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { return {
list: records.slice(start, start + pageSize), list: records.map((record) => this.toUnlockRecord(record)),
pagination: { pagination: { page, pageSize, total },
page,
pageSize,
total: records.length,
},
}; };
} }
private isUnlocked(userId: number, episodeId: number) { const records = [...this.unlocks].reverse();
return {
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { page, pageSize, total: this.unlocks.length },
};
}
private async isUnlocked(userId: number, episodeId: number): Promise<boolean> {
if (this.hasDatabase()) {
const count = await this.dataSource
.getRepository(UserEpisodeUnlock)
.count({ where: { userId, episodeId } });
return count > 0;
}
return this.unlocks.some( return this.unlocks.some(
(unlock) => unlock.userId === userId && unlock.episodeId === episodeId, (unlock) => unlock.userId === userId && unlock.episodeId === episodeId,
); );
} }
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toOrderRecord(order: Order): OrderRecord {
return {
id: Number(order.id),
orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId,
userId: Number(order.userId),
albumId: Number(order.albumId),
episodeId: Number(order.episodeId),
tiktokOrderId: order.tiktokOrderId,
amount: order.amount,
status: order.status as OrderStatus,
providerTransactionId: order.providerTransactionId,
createdAt: this.toIso(order.createdAt),
updatedAt: this.toIso(order.updatedAt),
};
}
private toUnlockRecord(unlock: UserEpisodeUnlock): UserEpisodeUnlockRecord {
return {
id: Number(unlock.id),
userId: Number(unlock.userId),
episodeId: Number(unlock.episodeId),
source: unlock.source as 'payment' | 'admin' | 'free',
orderNo: unlock.orderNo,
createdAt: this.toIso(unlock.createdAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private createOrderNo() { private createOrderNo() {
return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`; return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
} }

View File

@@ -17,3 +17,16 @@ export class PlayerController {
return this.playerService.getEpisodePlayer(user, Number(id)); return this.playerService.getEpisodePlayer(user, Number(id));
} }
} }
@ApiTags('Player')
@Controller('/api/app/v1/episodes')
export class EpisodePlayerController {
constructor(private readonly playerService: PlayerService) {}
@Get('/:id/player')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
getEpisodePlayer(@CurrentUser() user: UserRecord, @Param('id') id: string) {
return this.playerService.getEpisodePlayer(user, Number(id));
}
}

View File

@@ -3,12 +3,12 @@ import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module'; import { DramasModule } from '../dramas/dramas.module';
import { OrdersModule } from '../orders/orders.module'; import { OrdersModule } from '../orders/orders.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { PlayerController } from './player.controller'; import { EpisodePlayerController, PlayerController } from './player.controller';
import { PlayerService } from './player.service'; import { PlayerService } from './player.service';
@Module({ @Module({
imports: [AuthModule, DramasModule, OrdersModule, UsersModule], imports: [AuthModule, DramasModule, OrdersModule, UsersModule],
controllers: [PlayerController], controllers: [PlayerController, EpisodePlayerController],
providers: [PlayerService], providers: [PlayerService],
}) })
export class PlayerModule {} export class PlayerModule {}

View File

@@ -10,9 +10,9 @@ export class PlayerService {
private readonly ordersService: OrdersService, private readonly ordersService: OrdersService,
) {} ) {}
getEpisodePlayer(user: UserRecord, episodeId: number) { async getEpisodePlayer(user: UserRecord, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const hasPermission = this.ordersService.hasEpisodePermission( const hasPermission = await this.ordersService.hasEpisodePermission(
user.id, user.id,
episode.id, episode.id,
); );

View File

@@ -1,6 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, Optional } from "@nestjs/common";
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { InjectDataSource } from "@nestjs/typeorm";
import { UserRecord } from './interfaces/user-record.interface'; import { DataSource } from "typeorm";
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
import { User } from "./entities/user.entity";
import { UserRecord } from "./interfaces/user-record.interface";
interface UpsertTikTokUserInput { interface UpsertTikTokUserInput {
tiktokOpenId: string; tiktokOpenId: string;
@@ -19,10 +22,39 @@ export class UsersService {
private userSequence = 1; private userSequence = 1;
private readonly users: UserRecord[] = []; private readonly users: UserRecord[] = [];
upsertTikTokUser(input: UpsertTikTokUserInput) { constructor(
const existing = this.users.find( @Optional()
(user) => user.tiktokOpenId === input.tiktokOpenId, @InjectDataSource()
); readonly dataSource?: DataSource,
) {}
async upsertTikTokUser(input: UpsertTikTokUserInput) {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(User);
const existing = await repository.findOne({
where: { tiktokOpenId: input.tiktokOpenId },
});
if (existing) {
existing.tiktokUnionId = input.tiktokUnionId ?? existing.tiktokUnionId;
existing.nickname = input.nickname ?? existing.nickname;
existing.avatarUrl = input.avatarUrl ?? existing.avatarUrl;
const saved = await repository.save(existing);
return { user: this.toRecord(saved), isNewUser: false };
}
const user = repository.create({
tiktokOpenId: input.tiktokOpenId,
tiktokUnionId: input.tiktokUnionId,
nickname: input.nickname,
avatarUrl: input.avatarUrl,
status: "active",
});
const saved = await repository.save(user);
return { user: this.toRecord(saved), isNewUser: true };
}
const existing = this.users.find((user) => user.tiktokOpenId === input.tiktokOpenId);
const now = new Date().toISOString(); const now = new Date().toISOString();
if (existing) { if (existing) {
@@ -39,7 +71,7 @@ export class UsersService {
tiktokUnionId: input.tiktokUnionId, tiktokUnionId: input.tiktokUnionId,
nickname: input.nickname, nickname: input.nickname,
avatarUrl: input.avatarUrl, avatarUrl: input.avatarUrl,
status: 'active', status: "active",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -48,34 +80,82 @@ export class UsersService {
return { user, isNewUser: true }; return { user, isNewUser: true };
} }
findById(id: number) { async findById(id: number) {
if (this.hasDatabase()) {
const user = await this.dataSource.getRepository(User).findOne({
where: { id },
});
return user ? this.toRecord(user) : undefined;
}
return this.users.find((user) => user.id === id); return this.users.find((user) => user.id === id);
} }
listUsers(pagination: PaginationInput): PaginatedResponse<UserRecord> { async listUsers(pagination: PaginationInput): Promise<PaginatedResponse<UserRecord>> {
if (this.hasDatabase()) {
const page = Math.max(pagination.page, 1);
const pageSize = Math.min(Math.max(pagination.pageSize, 1), 100);
const repository = this.dataSource.getRepository(User);
const [records, total] = await repository.findAndCount({
order: { id: "DESC" },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((user) => this.toRecord(user)),
pagination: { page, pageSize, total },
};
}
return this.paginate([...this.users].reverse(), pagination); return this.paginate([...this.users].reverse(), pagination);
} }
getUserOrThrow(id: number) { async getUserOrThrow(id: number) {
const user = this.findById(id); const user = await this.findById(id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException("User not found");
} }
return user; return user;
} }
updateStatus(id: number, status: 'active' | 'disabled') { async updateStatus(id: number, status: "active" | "disabled") {
const user = this.getUserOrThrow(id); if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(User);
const user = await repository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException("User not found");
}
user.status = status;
return this.toRecord(await repository.save(user));
}
const user = await this.getUserOrThrow(id);
user.status = status; user.status = status;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
return user; return user;
} }
private paginate<T>( private hasDatabase(): this is this & { dataSource: DataSource } {
records: T[], return Boolean(this.dataSource?.isInitialized);
input: PaginationInput, }
): PaginatedResponse<T> {
private toRecord(user: User): UserRecord {
return {
id: Number(user.id),
tiktokOpenId: user.tiktokOpenId,
tiktokUnionId: user.tiktokUnionId,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
status: user.status as "active" | "disabled",
createdAt: user.createdAt?.toISOString?.() ?? new Date().toISOString(),
updatedAt: user.updatedAt?.toISOString?.() ?? new Date().toISOString(),
};
}
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1); const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;

View File

@@ -0,0 +1,231 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Data management and media upload flow', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('creates a drama, configures episode slots, completes a direct upload, and exposes approved published content to app APIs', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Upload Flow Drama',
description: 'Drama created by admin before uploading episodes.',
coverUrl: 'https://cdn.example.com/upload-flow-cover.jpg',
type: 'romance',
region: 'TH',
language: 'th',
status: 'draft',
})
.expect(201);
const dramaId = drama.body.data.id as number;
const slots = await request(app.getHttpServer())
.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ episodeCount: 3 })
.expect(201);
expect(slots.body.data.list).toHaveLength(3);
expect(slots.body.data.list[0]).toMatchObject({
dramaId,
episodeNo: 1,
title: '第1集',
publishStatus: 'draft',
reviewStatus: 'not_submitted',
});
const episodeId = slots.body.data.list[0].id as number;
const uploadTask = await request(app.getHttpServer())
.post('/api/admin/v1/media/upload-tasks')
.set('Authorization', `Bearer ${adminToken}`)
.send({
episodeId,
fileName: 'episode-1.mp4',
contentType: 'video/mp4',
fileSize: 1024,
})
.expect(201);
expect(uploadTask.body.data).toMatchObject({
jobId: expect.any(String),
uploadUrl: expect.stringContaining('mock-byteplus-upload'),
bucket: expect.any(String),
objectKey: expect.stringContaining('episode-1.mp4'),
status: 'pending',
});
const completed = await request(app.getHttpServer())
.patch(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/complete`)
.set('Authorization', `Bearer ${adminToken}`)
.send({})
.expect(200);
expect(completed.body.data).toMatchObject({
jobId: uploadTask.body.data.jobId,
episodeId,
status: 'reviewing',
byteplusVid: expect.stringMatching(/^mock_vid_/),
width: 1080,
height: 1920,
durationSeconds: 180,
});
await request(app.getHttpServer())
.post(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/review/retry`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/media/review-status/sync')
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await request(app.getHttpServer())
.patch(`/api/admin/v1/episodes/${episodeId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: '正式第1集',
coverUrl: 'https://cdn.example.com/episode-1-cover.jpg',
freeType: 'free',
publishStatus: 'published',
})
.expect(200);
await request(app.getHttpServer())
.patch(`/api/admin/v1/dramas/${dramaId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ status: 'published' })
.expect(200);
const appDetail = await request(app.getHttpServer())
.get(`/api/app/v1/dramas/${dramaId}`)
.expect(200);
expect(appDetail.body.data).toMatchObject({
id: dramaId,
title: 'Upload Flow Drama',
totalEpisodes: 3,
publishedEpisodes: 1,
});
const appEpisodes = await request(app.getHttpServer())
.get(`/api/app/v1/dramas/${dramaId}/episodes`)
.expect(200);
expect(appEpisodes.body.data.list).toEqual([
expect.objectContaining({
id: episodeId,
title: '正式第1集',
reviewStatus: 'approved',
publishStatus: 'published',
byteplusVid: completed.body.data.byteplusVid,
}),
]);
});
it('syncs TikTok mock analytics and returns overview plus drama and episode metrics', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Analytics Drama',
coverUrl: 'https://cdn.example.com/analytics-cover.jpg',
type: 'action',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Analytics Episode',
coverUrl: 'https://cdn.example.com/analytics-episode.jpg',
videoUrl: 'https://cdn.example.com/analytics-episode.mp4',
byteplusVid: 'mock_vid_analytics',
durationSeconds: 180,
reviewStatus: 'approved',
publishStatus: 'published',
status: 'published',
})
.expect(201);
const sync = await request(app.getHttpServer())
.post('/api/admin/v1/data/sync')
.set('Authorization', `Bearer ${adminToken}`)
.send({ dramaId: drama.body.data.id })
.expect(201);
expect(sync.body.data).toMatchObject({
status: 'completed',
dramaCount: expect.any(Number),
episodeCount: expect.any(Number),
});
const overview = await request(app.getHttpServer())
.get('/api/admin/v1/data/overview')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(overview.body.data).toMatchObject({
totalPlays: expect.any(Number),
totalLikes: expect.any(Number),
totalShares: expect.any(Number),
totalComments: expect.any(Number),
});
const dramaMetrics = await request(app.getHttpServer())
.get('/api/admin/v1/data/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(dramaMetrics.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
dramaId: drama.body.data.id,
title: 'Analytics Drama',
plays: expect.any(Number),
}),
]),
);
const episodeMetrics = await request(app.getHttpServer())
.get(`/api/admin/v1/data/episodes?dramaId=${drama.body.data.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(episodeMetrics.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeId: episode.body.data.id,
title: 'Analytics Episode',
plays: expect.any(Number),
}),
]);
});
});

View File

@@ -0,0 +1,185 @@
import { AdminService } from '../src/modules/admin/admin.service';
import { DataService } from '../src/modules/data/data.service';
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
import { DramasService } from '../src/modules/dramas/dramas.service';
import { LogsService } from '../src/modules/logs/logs.service';
import { UsersService } from '../src/modules/users/users.service';
function createRepository<T extends { id?: number }>() {
const records: T[] = [];
return {
records,
create: jest.fn((input: Partial<T>) => input as T),
save: jest.fn(async (input: T) => {
if (!input.id) {
input.id = records.length + 1;
}
const index = records.findIndex((item) => item.id === input.id);
if (index >= 0) {
records[index] = input;
} else {
records.push(input);
}
return input;
}),
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
records.find((item) =>
Object.entries(where).every(
([key, value]) => item[key as keyof T] === value,
),
) ?? null,
),
find: jest.fn(async () => [...records]),
count: jest.fn(async () => records.length),
};
}
function createDataSource(repositories: Record<string, unknown>) {
return {
isInitialized: true,
getRepository: jest.fn((entity: { name: string }) => {
const repository = repositories[entity.name];
if (!repository) {
throw new Error(`Missing repository for ${entity.name}`);
}
return repository;
}),
};
}
describe('database-backed persistence', () => {
it('persists permissions and admin profile changes through TypeORM repositories', async () => {
const permissionRepository = createRepository();
const roleRepository = createRepository();
const adminUserRepository = createRepository();
const dataSource = createDataSource({
Permission: permissionRepository,
Role: roleRepository,
AdminUser: adminUserRepository,
});
const service = new AdminService(
{ signAsync: jest.fn() } as never,
dataSource as never,
);
await service.onModuleInit();
const permissions = await service.listPermissions({ page: 1, pageSize: 50 });
expect(permissions.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'permission:read', name: '查看权限' }),
expect.objectContaining({ code: 'data:read', name: '查看数据' }),
]),
);
const updated = await service.updateProfile(1, {
nickname: '数据库管理员',
email: 'db-admin@example.com',
});
expect(updated).toMatchObject({
id: 1,
nickname: '数据库管理员',
email: 'db-admin@example.com',
});
expect(adminUserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
nickname: '数据库管理员',
email: 'db-admin@example.com',
}),
);
});
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
const userRepository = createRepository();
const dataSource = createDataSource({ User: userRepository });
const service = new UsersService(dataSource as never);
const result = await service.upsertTikTokUser({
tiktokOpenId: 'open-db-1',
nickname: 'DB User',
});
expect(result.isNewUser).toBe(true);
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
tiktokOpenId: 'open-db-1',
nickname: 'DB User',
status: 'active',
}),
);
});
it('persists request logs and playback error logs through TypeORM repositories', async () => {
const requestLogRepository = createRepository();
const playbackErrorLogRepository = createRepository();
const dataSource = createDataSource({
RequestLog: requestLogRepository,
PlaybackErrorLog: playbackErrorLogRepository,
});
const service = new LogsService(dataSource as never);
await service.recordRequest({
requestId: '00000000-0000-4000-8000-000000000001',
method: 'GET',
path: '/api/admin/v1/logs',
statusCode: 200,
responseCode: 0,
message: 'success',
costMs: 3,
});
await service.createPlaybackError({
requestId: '00000000-0000-4000-8000-000000000002',
dramaId: 1,
episodeId: 2,
playUrl: 'https://cdn.example.com/video.mp4',
playerErrorCode: 'NETWORK',
message: 'network error',
occurredAt: '2026-07-01T00:00:00.000Z',
});
expect(requestLogRepository.save).toHaveBeenCalled();
expect(playbackErrorLogRepository.save).toHaveBeenCalled();
});
it('persists synced drama and episode metrics through TypeORM repositories', async () => {
const dramaMetricRepository = createRepository();
const episodeMetricRepository = createRepository();
const syncJobRepository = createRepository();
const dataSource = createDataSource({
DramaMetric: dramaMetricRepository,
EpisodeMetric: episodeMetricRepository,
DataSyncJob: syncJobRepository,
});
const dramasService = new DramasService();
const tiktokDataProvider = new TikTokDataProvider();
const service = new DataService(
dramasService,
tiktokDataProvider,
dataSource as never,
);
const drama = await dramasService.createDrama({
title: 'DB Metrics',
coverUrl: 'https://cdn.example.com/cover.jpg',
type: 'action',
status: PublishStatus.Published,
});
await dramasService.createEpisode({
dramaId: drama.id,
episodeNo: 1,
title: 'DB Metrics Episode',
coverUrl: drama.coverUrl,
videoUrl: 'https://cdn.example.com/video.mp4',
durationSeconds: 180,
status: PublishStatus.Published,
});
await service.sync({ dramaId: drama.id });
expect(dramaMetricRepository.save).toHaveBeenCalled();
expect(episodeMetricRepository.save).toHaveBeenCalled();
expect(syncJobRepository.save).toHaveBeenCalled();
});
});

View File

@@ -122,6 +122,89 @@ describe('Dramas and episodes', () => {
}); });
}); });
it('creates episode slots when a new drama declares total episodes', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Preconfigured Slots Drama',
coverUrl: 'https://cdn.example.com/preconfigured-slots.jpg',
type: 'romance',
status: 'draft',
totalEpisodes: 2,
})
.expect(201);
expect(drama.body.data).toMatchObject({
title: 'Preconfigured Slots Drama',
totalEpisodes: 2,
publishedEpisodes: 0,
});
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(episodes.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: '第1集',
publishStatus: 'draft',
}),
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 2,
title: '第2集',
publishStatus: 'draft',
}),
]);
});
it('updates an episode from the drama detail episodes endpoint', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Nested Episode Update Drama',
coverUrl: 'https://cdn.example.com/nested-update.jpg',
type: 'action',
status: 'draft',
totalEpisodes: 1,
})
.expect(201);
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const episodeId = episodes.body.data.list[0].id;
const updated = await request(app.getHttpServer())
.patch(`/api/admin/v1/dramas/${drama.body.data.id}/episodes/${episodeId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
price: 199,
})
.expect(200);
expect(updated.body.data).toMatchObject({
id: episodeId,
dramaId: drama.body.data.id,
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
isPaid: true,
price: 199,
});
});
it('returns only published dramas and published episodes to app APIs', async () => { it('returns only published dramas and published episodes to app APIs', async () => {
const published = await request(app.getHttpServer()) const published = await request(app.getHttpServer())
.post('/api/admin/v1/dramas') .post('/api/admin/v1/dramas')
@@ -129,6 +212,8 @@ describe('Dramas and episodes', () => {
.send({ .send({
title: 'Published Drama', title: 'Published Drama',
coverUrl: 'https://cdn.example.com/pub.jpg', coverUrl: 'https://cdn.example.com/pub.jpg',
portraitCoverUrl: 'https://cdn.example.com/pub-portrait.jpg',
landscapeCoverUrl: 'https://cdn.example.com/pub-landscape.jpg',
type: 'action', type: 'action',
status: 'published', status: 'published',
}) })
@@ -182,6 +267,11 @@ describe('Dramas and episodes', () => {
expect.objectContaining({ title: 'Published Drama' }), expect.objectContaining({ title: 'Published Drama' }),
]), ]),
); );
const publishedDrama = dramas.body.data.list.find(
(item: { title: string }) => item.title === 'Published Drama',
);
expect(publishedDrama).not.toHaveProperty('portraitCoverUrl');
expect(publishedDrama).not.toHaveProperty('landscapeCoverUrl');
expect(dramas.body.data.list).not.toEqual( expect(dramas.body.data.list).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]), expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
); );
@@ -197,4 +287,219 @@ describe('Dramas and episodes', () => {
}), }),
]); ]);
}); });
it('filters published dramas by title and defaults app page size to 10', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Searchable Moon Drama',
coverUrl: 'https://cdn.example.com/searchable-moon.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Hidden Sun Drama',
coverUrl: 'https://cdn.example.com/hidden-sun.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas')
.query({ title: 'Moon' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Searchable Moon Drama' }),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Hidden Sun Drama' }),
]),
);
});
it('filters admin dramas by title and recommended status', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Search Drama',
coverUrl: 'https://cdn.example.com/recommended-search.jpg',
type: 'romance',
status: 'draft',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Hidden Drama',
coverUrl: 'https://cdn.example.com/recommended-hidden.jpg',
type: 'romance',
status: 'draft',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.query({ title: 'Recommended', isRecommended: 'true' })
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Recommended Search Drama',
isRecommended: true,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Recommended Hidden Drama' }),
]),
);
});
it('returns a published recommended drama list to app APIs', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Recommended Drama',
coverUrl: 'https://cdn.example.com/published-recommended.jpg',
type: 'romance',
status: 'published',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Normal Drama',
coverUrl: 'https://cdn.example.com/published-normal.jpg',
type: 'romance',
status: 'published',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/recommended')
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Published Recommended Drama',
isRecommended: true,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Published Normal Drama' }),
]),
);
});
it('presearches published approved videos by keyword for mini app clients', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Skyline Keyword Saga',
description: 'A searchable drama description.',
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
description: 'The first skyline episode.',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 2,
title: 'Hidden Draft Video',
description: 'Contains skyline but is not published.',
coverUrl: 'https://cdn.example.com/skyline-ep2.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep2.mp4',
byteplusVid: 'vid_skyline_002',
durationSeconds: 180,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'approved',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: 'skyline' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
dramaTitle: 'Skyline Keyword Saga',
dramaCoverUrl: 'https://cdn.example.com/skyline-drama.jpg',
}),
]);
const emptyKeyword = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: ' ' })
.expect(200);
expect(emptyKeyword.body.data).toEqual({
list: [],
pagination: { page: 1, pageSize: 10, total: 0 },
});
});
}); });

View File

@@ -128,4 +128,164 @@ describe('Auth, orders, and playback', () => {
videoUrl: 'https://cdn.example.com/paid-ep.mp4', videoUrl: 'https://cdn.example.com/paid-ep.mp4',
}); });
}); });
it('records, updates, and isolates mini app playback history', async () => {
const firstLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-1',
mockOpenId: 'history-user-1',
nickname: 'History User 1',
})
.expect(201);
const firstToken = firstLogin.body.data.accessToken;
const secondLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-2',
mockOpenId: 'history-user-2',
nickname: 'History User 2',
})
.expect(201);
const secondToken = secondLogin.body.data.accessToken;
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'History Drama',
coverUrl: 'https://cdn.example.com/history-drama.jpg',
type: 'history',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
videoUrl: 'https://cdn.example.com/history-episode.mp4',
durationSeconds: 300,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(401);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 301,
completed: false,
occurredAt: '2026-07-02T10:05:00.000Z',
})
.expect(201);
const firstHistory = await request(app.getHttpServer())
.get('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.expect(200);
expect(firstHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 1 },
list: [
{
dramaId: drama.body.data.id,
episodeId: episode.body.data.id,
episodeNo: 1,
title: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
progressSeconds: 300,
durationSeconds: 300,
completed: true,
lastPlayedAt: '2026-07-02T10:05:00.000Z',
dramaTitle: 'History Drama',
dramaCoverUrl: 'https://cdn.example.com/history-drama.jpg',
},
],
});
const secondHistory = await request(app.getHttpServer())
.get('/api/app/v1/history')
.set('Authorization', `Bearer ${secondToken}`)
.expect(200);
expect(secondHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 0 },
list: [],
});
});
it('rejects playback history for unavailable episodes', async () => {
const login = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-3',
mockOpenId: 'history-user-3',
})
.expect(201);
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Draft History Drama',
coverUrl: 'https://cdn.example.com/draft-history-drama.jpg',
type: 'history',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Draft History Episode',
coverUrl: 'https://cdn.example.com/draft-history-episode.jpg',
durationSeconds: 120,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'not_submitted',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 10,
})
.expect(404);
});
}); });