Compare commits
9 Commits
master
...
61a00286fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a00286fb | |||
| 79aeac7f37 | |||
| fca247c3f2 | |||
| f2b1043cbc | |||
| 0eeeea0c4d | |||
| f342312b92 | |||
| d3083bf16b | |||
| 026665b2cc | |||
| 5fa8c5e0a9 |
8
.env
8
.env
@@ -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
|
||||||
|
|||||||
@@ -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
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 390,
|
||||||
|
"eslintIntegration": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"stylelintIntegration": true
|
||||||
|
}
|
||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
AdminModule,
|
DatabaseModule,
|
||||||
HealthModule,
|
AdminModule,
|
||||||
LogsModule,
|
HealthModule,
|
||||||
DramasModule,
|
LogsModule,
|
||||||
MediaModule,
|
DramasModule,
|
||||||
AuthModule,
|
DataModule,
|
||||||
OrdersModule,
|
MediaModule,
|
||||||
PlayerModule,
|
AuthModule,
|
||||||
],
|
OrdersModule,
|
||||||
|
PlayerModule,
|
||||||
|
HistoryModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -8,35 +8,27 @@ import { RequestIdMiddleware } from "./common/middleware/request-id.middleware";
|
|||||||
import { LogsService } from "./modules/logs/logs.service";
|
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',
|
credentials: true,
|
||||||
'http://127.0.0.1:5173',
|
});
|
||||||
],
|
app.use(RequestIdMiddleware);
|
||||||
credentials: true,
|
app.useGlobalPipes(
|
||||||
});
|
new ValidationPipe({
|
||||||
app.use(RequestIdMiddleware);
|
whitelist: true,
|
||||||
app.useGlobalPipes(
|
transform: true,
|
||||||
new ValidationPipe({
|
forbidNonWhitelisted: true,
|
||||||
whitelist: true,
|
}),
|
||||||
transform: true,
|
);
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/main.ts
14
src/main.ts
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/modules/data/data.controller.ts
Normal file
64
src/modules/data/data.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/data/data.module.ts
Normal file
13
src/modules/data/data.module.ts
Normal 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 {}
|
||||||
312
src/modules/data/data.service.ts
Normal file
312
src/modules/data/data.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/modules/data/entities/data-sync-job.entity.ts
Normal file
26
src/modules/data/entities/data-sync-job.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
48
src/modules/data/entities/drama-metric.entity.ts
Normal file
48
src/modules/data/entities/drama-metric.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/modules/data/entities/episode-metric.entity.ts
Normal file
55
src/modules/data/entities/episode-metric.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
29
src/modules/data/providers/tiktok-data.provider.ts
Normal file
29
src/modules/data/providers/tiktok-data.provider.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/modules/database/database.module.ts
Normal file
55
src/modules/database/database.module.ts
Normal 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 {}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/api/admin/v1/episodes')
|
@Get("/api/admin/v1/dramas/:id")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@RequirePermissions('episode:create')
|
@RequirePermissions("drama:read")
|
||||||
createEpisode(@Body() dto: CreateEpisodeDto) {
|
getDrama(@Param("id") id: string) {
|
||||||
return this.dramasService.createEpisode(dto);
|
return this.dramasService.getDramaOrThrow(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/api/admin/v1/dramas/:id/submit-review')
|
@Patch("/api/admin/v1/dramas/:id")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@RequirePermissions('drama:update')
|
@RequirePermissions("drama:update")
|
||||||
submitReview(@Param('id') id: string) {
|
updateDrama(@Param("id") id: string, @Body() dto: UpdateDramaDto) {
|
||||||
return this.dramasService.submitReview(Number(id));
|
return this.dramasService.updateDrama(Number(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('/api/admin/v1/episodes/:id/publish-status')
|
@Post("/api/admin/v1/dramas/:id/episodes/batch")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@RequirePermissions('episode:update')
|
@RequirePermissions("episode:create")
|
||||||
updatePublishStatus(
|
configureEpisodes(@Param("id") id: string, @Body() dto: ConfigureEpisodesDto) {
|
||||||
@Param('id') id: string,
|
return this.dramasService.configureEpisodes(Number(id), dto);
|
||||||
@Body() dto: { publishStatus: PublishStatus },
|
}
|
||||||
) {
|
|
||||||
return this.dramasService.updateEpisodePublishStatus(
|
|
||||||
Number(id),
|
|
||||||
dto.publishStatus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/api/app/v1/dramas')
|
@Get("/api/admin/v1/dramas/:id/episodes")
|
||||||
listPublishedDramas(
|
@ApiBearerAuth()
|
||||||
@Query('page') page?: string,
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@Query('pageSize') pageSize?: string,
|
@RequirePermissions("episode:update")
|
||||||
) {
|
listAdminEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
return this.dramasService.listPublishedDramas({
|
return this.dramasService.listAdminEpisodes(Number(id), {
|
||||||
page: Number(page) || 1,
|
page: Number(page) || 1,
|
||||||
pageSize: Number(pageSize) || 20,
|
pageSize: Number(pageSize) || 20,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/api/app/v1/dramas/:id/episodes')
|
@Post("/api/admin/v1/episodes")
|
||||||
listPublishedEpisodes(
|
@ApiBearerAuth()
|
||||||
@Param('id') id: string,
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@Query('page') page?: string,
|
@RequirePermissions("episode:create")
|
||||||
@Query('pageSize') pageSize?: string,
|
createEpisode(@Body() dto: CreateEpisodeDto) {
|
||||||
) {
|
return this.dramasService.createEpisode(dto);
|
||||||
return this.dramasService.listPublishedEpisodes(Number(id), {
|
}
|
||||||
page: Number(page) || 1,
|
|
||||||
pageSize: Number(pageSize) || 20,
|
@Patch("/api/admin/v1/episodes/:id")
|
||||||
});
|
@ApiBearerAuth()
|
||||||
}
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
|
@RequirePermissions("episode:update")
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("/api/admin/v1/episodes/:id/publish-status")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
|
@RequirePermissions("episode:update")
|
||||||
|
updatePublishStatus(@Param("id") id: string, @Body() dto: { publishStatus: PublishStatus }) {
|
||||||
|
return this.dramasService.updateEpisodePublishStatus(Number(id), dto.publishStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/api/app/v1/dramas")
|
||||||
|
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.dramasService.listPublishedDramas({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageSize: Number(pageSize) || 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/api/app/v1/dramas/recommended")
|
||||||
|
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.dramasService.listRecommendedDramas({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
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), {
|
||||||
|
page: Number(page) || 1,
|
||||||
|
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
10
src/modules/dramas/dto/configure-episodes.dto.ts
Normal file
10
src/modules/dramas/dto/configure-episodes.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
4
src/modules/dramas/dto/update-drama.dto.ts
Normal file
4
src/modules/dramas/dto/update-drama.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateDramaDto } from './create-drama.dto';
|
||||||
|
|
||||||
|
export class UpdateDramaDto extends PartialType(CreateDramaDto) {}
|
||||||
4
src/modules/dramas/dto/update-episode.dto.ts
Normal file
4
src/modules/dramas/dto/update-episode.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateEpisodeDto } from './create-episode.dto';
|
||||||
|
|
||||||
|
export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
30
src/modules/history/dto/upsert-history.dto.ts
Normal file
30
src/modules/history/dto/upsert-history.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
43
src/modules/history/entities/user-playback-history.entity.ts
Normal file
43
src/modules/history/entities/user-playback-history.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
37
src/modules/history/history.controller.ts
Normal file
37
src/modules/history/history.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/history/history.module.ts
Normal file
13
src/modules/history/history.module.ts
Normal 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 {}
|
||||||
223
src/modules/history/history.service.ts
Normal file
223
src/modules/history/history.service.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/modules/history/interfaces/history-record.interface.ts
Normal file
27
src/modules/history/interfaces/history-record.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/modules/media/dto/complete-direct-upload-task.dto.ts
Normal file
9
src/modules/media/dto/complete-direct-upload-task.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CompleteDirectUploadTaskDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
providerUploadId?: string;
|
||||||
|
}
|
||||||
22
src/modules/media/dto/create-direct-upload-task.dto.ts
Normal file
22
src/modules/media/dto/create-direct-upload-task.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/modules/media/entities/cover.entity.ts
Normal file
21
src/modules/media/entities/cover.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,40 +1,68 @@
|
|||||||
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,
|
}
|
||||||
) {
|
|
||||||
return this.mediaService.listUploadJobs({
|
@Get("/upload-tasks/:jobId")
|
||||||
page: Number(page) || 1,
|
@RequirePermissions("media:read")
|
||||||
pageSize: Number(pageSize) || 20,
|
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({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageSize: Number(pageSize) || 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/modules/media/providers/byteplus-media.provider.ts
Normal file
58
src/modules/media/providers/byteplus-media.provider.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 amount = episode.price ?? episode.unlockPrice ?? 0;
|
||||||
|
const orderNo = this.createOrderNo();
|
||||||
|
const tradeOrderId = this.createTradeOrderId();
|
||||||
|
this.orderSequence++;
|
||||||
|
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(Order);
|
||||||
|
const entity = repository.create({
|
||||||
|
orderNo,
|
||||||
|
tradeOrderId,
|
||||||
|
userId: user.id,
|
||||||
|
albumId: episode.albumId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
amount,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
return this.toOrderRecord(await repository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const order: OrderRecord = {
|
const order: OrderRecord = {
|
||||||
id: this.orderSequence++,
|
id: this.orders.length + 1,
|
||||||
orderNo: this.createOrderNo(),
|
orderNo,
|
||||||
tradeOrderId: this.createTradeOrderId(),
|
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',
|
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 {
|
||||||
|
list: records.map((record) => this.toUnlockRecord(record)),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = [...this.unlocks].reverse();
|
||||||
return {
|
return {
|
||||||
list: records.slice(start, start + pageSize),
|
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
|
||||||
pagination: {
|
pagination: { page, pageSize, total: this.unlocks.length },
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: records.length,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUnlocked(userId: number, episodeId: number) {
|
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')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,92 +1,172 @@
|
|||||||
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;
|
||||||
tiktokUnionId?: string;
|
tiktokUnionId?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
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,
|
||||||
const now = new Date().toISOString();
|
) {}
|
||||||
|
|
||||||
if (existing) {
|
async upsertTikTokUser(input: UpsertTikTokUserInput) {
|
||||||
existing.tiktokUnionId = input.tiktokUnionId ?? existing.tiktokUnionId;
|
if (this.hasDatabase()) {
|
||||||
existing.nickname = input.nickname ?? existing.nickname;
|
const repository = this.dataSource.getRepository(User);
|
||||||
existing.avatarUrl = input.avatarUrl ?? existing.avatarUrl;
|
const existing = await repository.findOne({
|
||||||
existing.updatedAt = now;
|
where: { tiktokOpenId: input.tiktokOpenId },
|
||||||
return { user: existing, isNewUser: false };
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.tiktokUnionId = input.tiktokUnionId ?? existing.tiktokUnionId;
|
||||||
|
existing.nickname = input.nickname ?? existing.nickname;
|
||||||
|
existing.avatarUrl = input.avatarUrl ?? existing.avatarUrl;
|
||||||
|
existing.updatedAt = now;
|
||||||
|
return { user: existing, isNewUser: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: UserRecord = {
|
||||||
|
id: this.userSequence++,
|
||||||
|
tiktokOpenId: input.tiktokOpenId,
|
||||||
|
tiktokUnionId: input.tiktokUnionId,
|
||||||
|
nickname: input.nickname,
|
||||||
|
avatarUrl: input.avatarUrl,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.users.push(user);
|
||||||
|
return { user, isNewUser: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserRecord = {
|
async findById(id: number) {
|
||||||
id: this.userSequence++,
|
if (this.hasDatabase()) {
|
||||||
tiktokOpenId: input.tiktokOpenId,
|
const user = await this.dataSource.getRepository(User).findOne({
|
||||||
tiktokUnionId: input.tiktokUnionId,
|
where: { id },
|
||||||
nickname: input.nickname,
|
});
|
||||||
avatarUrl: input.avatarUrl,
|
return user ? this.toRecord(user) : undefined;
|
||||||
status: 'active',
|
}
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.users.push(user);
|
return this.users.find((user) => user.id === id);
|
||||||
return { user, isNewUser: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
findById(id: number) {
|
|
||||||
return this.users.find((user) => user.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
listUsers(pagination: PaginationInput): PaginatedResponse<UserRecord> {
|
|
||||||
return this.paginate([...this.users].reverse(), pagination);
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserOrThrow(id: number) {
|
|
||||||
const user = this.findById(id);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
updateStatus(id: number, status: 'active' | 'disabled') {
|
return {
|
||||||
const user = this.getUserOrThrow(id);
|
list: records.map((user) => this.toRecord(user)),
|
||||||
user.status = status;
|
pagination: { page, pageSize, total },
|
||||||
user.updatedAt = new Date().toISOString();
|
};
|
||||||
return user;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private paginate<T>(
|
return this.paginate([...this.users].reverse(), pagination);
|
||||||
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 {
|
async getUserOrThrow(id: number) {
|
||||||
list: records.slice(start, start + pageSize),
|
const user = await this.findById(id);
|
||||||
pagination: {
|
if (!user) {
|
||||||
page,
|
throw new NotFoundException("User not found");
|
||||||
pageSize,
|
}
|
||||||
total: records.length,
|
|
||||||
},
|
return user;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
async updateStatus(id: number, status: "active" | "disabled") {
|
||||||
|
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.updatedAt = new Date().toISOString();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||||
|
return Boolean(this.dataSource?.isInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
231
test/data-media-flow.e2e-spec.ts
Normal file
231
test/data-media-flow.e2e-spec.ts
Normal 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),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
test/database-persistence.e2e-spec.ts
Normal file
185
test/database-persistence.e2e-spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"noImplicitAny": false
|
"noImplicitAny": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user