Files
cyh-tk-backend/src/modules/orders/orders.service.ts
zhxiao1124 0eeeea0c4d feat: 新增剧集封面、集数管理与上传优化功能
1. 新增Cover实体类并注册到数据库模块
2. 为剧集添加总集数字段及自动创建集数槽功能
3. 新增管理员更新剧集集数接口
4. 优化发布剧集列表:支持标题过滤与默认分页大小10
5. 扩展MediaUploadJob实体字段,完善媒体上传流程
6. 重构订单与媒体服务,新增数据库持久化支持
7. 新增剧集标题过滤的端到端测试用例
2026-07-02 15:46:29 +08:00

296 lines
8.7 KiB
TypeScript

import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} 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 { UserRecord } from '../users/interfaces/user-record.interface';
import { CreateOrderDto } from './dto/create-order.dto';
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
import { Order } from './entities/order.entity';
import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity';
import {
OrderRecord,
OrderStatus,
UserEpisodeUnlockRecord,
} from './interfaces/order-records.interface';
@Injectable()
export class OrdersService {
private orderSequence = 1;
private unlockSequence = 1;
private readonly orders: OrderRecord[] = [];
private readonly unlocks: UserEpisodeUnlockRecord[] = [];
constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
if (!episode.isPaid) {
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 order: OrderRecord = {
id: this.orders.length + 1,
orderNo,
tradeOrderId,
userId: user.id,
albumId: episode.albumId,
episodeId: episode.id,
amount,
status: 'pending',
createdAt: now,
updatedAt: now,
};
this.orders.push(order);
return order;
}
async getPlayback(user: UserRecord, episodeId: number) {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const isUnlocked =
!episode.isPaid || (await this.isUnlocked(user.id, episodeId));
return {
episodeId: episode.id,
dramaId: episode.dramaId,
albumId: episode.albumId,
isLocked: !isUnlocked,
isPaid: episode.isPaid,
trialDurationSeconds: episode.trialDurationSeconds,
durationSeconds: episode.durationSeconds,
videoUrl: isUnlocked ? episode.videoUrl : null,
};
}
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(
(item) =>
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
);
if (!order) {
throw new NotFoundException('Order not found');
}
if (order.status === 'paid') {
return {
orderNo: order.orderNo,
status: order.status,
unlocked: await this.isUnlocked(order.userId, order.episodeId),
};
}
order.providerTransactionId = dto.providerTransactionId;
order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId;
order.status = dto.status;
order.updatedAt = new Date().toISOString();
if (
dto.status === 'paid' &&
!(await this.isUnlocked(order.userId, order.episodeId))
) {
this.unlocks.push({
id: this.unlockSequence++,
userId: order.userId,
episodeId: order.episodeId,
source: 'payment',
orderNo: order.orderNo,
createdAt: new Date().toISOString(),
});
}
return {
orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId,
status: order.status,
unlocked: await this.isUnlocked(order.userId, order.episodeId),
};
}
async hasEpisodePermission(userId: number, episodeId: number) {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
return !episode.isPaid || (await this.isUnlocked(userId, episodeId));
}
async listUnlockRecords(input: {
page: number;
pageSize: number;
}): Promise<PaginatedResponse<UserEpisodeUnlockRecord>> {
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(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 {
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { page, pageSize, total: this.unlocks.length },
};
}
private async isUnlocked(userId: number, episodeId: number): Promise<boolean> {
if (this.hasDatabase()) {
const count = await this.dataSource
.getRepository(UserEpisodeUnlock)
.count({ where: { userId, episodeId } });
return count > 0;
}
return this.unlocks.some(
(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() {
return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
}
private createTradeOrderId() {
return `TRADE${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
}
}