feat: 新增剧集封面、集数管理与上传优化功能
1. 新增Cover实体类并注册到数据库模块 2. 为剧集添加总集数字段及自动创建集数槽功能 3. 新增管理员更新剧集集数接口 4. 优化发布剧集列表:支持标题过滤与默认分页大小10 5. 扩展MediaUploadJob实体字段,完善媒体上传流程 6. 重构订单与媒体服务,新增数据库持久化支持 7. 新增剧集标题过滤的端到端测试用例
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
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';
|
||||
|
||||
@@ -20,7 +26,12 @@ export class OrdersService {
|
||||
private readonly orders: OrderRecord[] = [];
|
||||
private readonly unlocks: UserEpisodeUnlockRecord[] = [];
|
||||
|
||||
constructor(private readonly dramasService: DramasService) {}
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||
@@ -30,15 +41,34 @@ export class OrdersService {
|
||||
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.orderSequence++,
|
||||
orderNo: this.createOrderNo(),
|
||||
tradeOrderId: this.createTradeOrderId(),
|
||||
id: this.orders.length + 1,
|
||||
orderNo,
|
||||
tradeOrderId,
|
||||
userId: user.id,
|
||||
albumId: episode.albumId,
|
||||
episodeId: episode.id,
|
||||
amount: episode.price ?? episode.unlockPrice ?? 0,
|
||||
amount,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -51,10 +81,7 @@ export class OrdersService {
|
||||
async getPlayback(user: UserRecord, episodeId: number) {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
const isUnlocked =
|
||||
!episode.isPaid ||
|
||||
this.unlocks.some(
|
||||
(unlock) => unlock.userId === user.id && unlock.episodeId === episodeId,
|
||||
);
|
||||
!episode.isPaid || (await this.isUnlocked(user.id, episodeId));
|
||||
|
||||
return {
|
||||
episodeId: episode.id,
|
||||
@@ -68,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(
|
||||
(item) =>
|
||||
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
|
||||
@@ -81,7 +169,7 @@ export class OrdersService {
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
status: order.status,
|
||||
unlocked: this.isUnlocked(order.userId, order.episodeId),
|
||||
unlocked: await this.isUnlocked(order.userId, order.episodeId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +178,10 @@ export class OrdersService {
|
||||
order.status = dto.status;
|
||||
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({
|
||||
id: this.unlockSequence++,
|
||||
userId: order.userId,
|
||||
@@ -105,40 +196,95 @@ export class OrdersService {
|
||||
orderNo: order.orderNo,
|
||||
tradeOrderId: order.tradeOrderId,
|
||||
status: order.status,
|
||||
unlocked: this.isUnlocked(order.userId, order.episodeId),
|
||||
unlocked: await this.isUnlocked(order.userId, order.episodeId),
|
||||
};
|
||||
}
|
||||
|
||||
async hasEpisodePermission(userId: number, episodeId: number) {
|
||||
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;
|
||||
pageSize: number;
|
||||
}): PaginatedResponse<UserEpisodeUnlockRecord> {
|
||||
}): Promise<PaginatedResponse<UserEpisodeUnlockRecord>> {
|
||||
const page = Math.max(input.page, 1);
|
||||
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 {
|
||||
list: records.slice(start, start + pageSize),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: records.length,
|
||||
},
|
||||
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
|
||||
pagination: { page, pageSize, total: this.unlocks.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(
|
||||
(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')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user