1. 新增Cover实体类并注册到数据库模块 2. 为剧集添加总集数字段及自动创建集数槽功能 3. 新增管理员更新剧集集数接口 4. 优化发布剧集列表:支持标题过滤与默认分页大小10 5. 扩展MediaUploadJob实体字段,完善媒体上传流程 6. 重构订单与媒体服务,新增数据库持久化支持 7. 新增剧集标题过滤的端到端测试用例
296 lines
8.7 KiB
TypeScript
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')}`;
|
|
}
|
|
}
|