|
|
@@ -3,6 +3,7 @@ import {
|
|
|
Injectable,
|
|
|
BadRequestException,
|
|
|
NotFoundException,
|
|
|
+ Logger,
|
|
|
} from '@nestjs/common';
|
|
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
|
|
import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
@@ -20,17 +21,14 @@ import { nowSecBigInt } from '@box/common/time/time.util';
|
|
|
|
|
|
@Injectable()
|
|
|
export class ChannelService {
|
|
|
+ private readonly logger = new Logger(ChannelService.name);
|
|
|
+ private isBackfilling = false;
|
|
|
+
|
|
|
constructor(
|
|
|
private readonly mongoPrismaService: MongoPrismaService,
|
|
|
private readonly cacheSyncService: CacheSyncService,
|
|
|
) {}
|
|
|
|
|
|
- /**
|
|
|
- * Current epoch seconds (BigInt) for persisted timestamps.
|
|
|
- *
|
|
|
- * NOTE:
|
|
|
- * - We now keep `createAt`/`updateAt` as BigInt seconds.
|
|
|
- */
|
|
|
private now(): bigint {
|
|
|
return nowSecBigInt();
|
|
|
}
|
|
|
@@ -69,10 +67,90 @@ export class ChannelService {
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
+ private async generateNextChannelNo(): Promise<number> {
|
|
|
+ const last = await this.mongoPrismaService.channel.findFirst({
|
|
|
+ where: { channelNo: { isSet: true } },
|
|
|
+ orderBy: { channelNo: 'desc' },
|
|
|
+ select: { channelNo: true },
|
|
|
+ });
|
|
|
+
|
|
|
+ return (last?.channelNo ?? 0) + 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async backfillIds(): Promise<void> {
|
|
|
+ if (this.isBackfilling) {
|
|
|
+ this.logger.warn('backfill is already running, skipping.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.isBackfilling = true;
|
|
|
+ this.logger.log('Starting backfill of id...');
|
|
|
+ try {
|
|
|
+ const withoutId = await this.mongoPrismaService.channel.findMany({
|
|
|
+ where: {
|
|
|
+ OR: [{ channelNo: { isSet: false } }, { channelNo: null }],
|
|
|
+ },
|
|
|
+ orderBy: { createAt: 'asc' },
|
|
|
+ select: { id: true },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (withoutId.length === 0) {
|
|
|
+ this.logger.log('No channels need backfilling channelNo.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Found ${withoutId.length} channels without channelNo. Starting backfill...`,
|
|
|
+ );
|
|
|
+
|
|
|
+ let nextUid = await this.generateNextChannelNo();
|
|
|
+
|
|
|
+ for (const channel of withoutId) {
|
|
|
+ let assigned = false;
|
|
|
+
|
|
|
+ while (!assigned) {
|
|
|
+ try {
|
|
|
+ await this.mongoPrismaService.channel.update({
|
|
|
+ where: { id: channel.id },
|
|
|
+ data: { channelNo: nextUid },
|
|
|
+ });
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Backfilled channelNo ${nextUid} for channel id ${channel.id}`,
|
|
|
+ );
|
|
|
+
|
|
|
+ nextUid += 1;
|
|
|
+ assigned = true;
|
|
|
+ } catch (e: any) {
|
|
|
+ // Unique constraint violation → retry with a fresh number
|
|
|
+ if (e?.code === 'P2002') {
|
|
|
+ this.logger.warn(`Duplicate channelNo ${nextUid}, retrying...`);
|
|
|
+ nextUid = await this.generateNextChannelNo();
|
|
|
+ } else {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Backfilled ${withoutId.length} channelNos successfully.`,
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ this.isBackfilling = false;
|
|
|
+ this.logger.log('Finished backfilling process.');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
async create(dto: CreateChannelDto) {
|
|
|
+ await this.backfillIds().catch((e) => {
|
|
|
+ this.logger.error('Error during backfillIds:', e);
|
|
|
+ });
|
|
|
+
|
|
|
// Check for duplicate channel name
|
|
|
const existingChannel = await this.mongoPrismaService.channel.findFirst({
|
|
|
where: { name: dto.name },
|
|
|
+ select: { id: true },
|
|
|
});
|
|
|
|
|
|
if (existingChannel) {
|
|
|
@@ -81,20 +159,6 @@ export class ChannelService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // Check for duplicate channelId
|
|
|
- const existingChannelId = await this.mongoPrismaService.channel.findFirst({
|
|
|
- where: { channelId: dto.channelId },
|
|
|
- });
|
|
|
-
|
|
|
- if (existingChannelId) {
|
|
|
- throw new BadRequestException(
|
|
|
- `Channel with channelId "${dto.channelId}" already exists`,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- const isDefault = (await this.mongoPrismaService.channel.count()) === 0;
|
|
|
-
|
|
|
- const now = this.now();
|
|
|
const categoryIds = dto.categories?.map((c) => c.id) ?? [];
|
|
|
const tagIds = dto.tags?.map((t) => t.id) ?? [];
|
|
|
|
|
|
@@ -104,24 +168,49 @@ export class ChannelService {
|
|
|
]);
|
|
|
const tagNames = tags.map((t) => t.name.trim());
|
|
|
|
|
|
- const channel = await this.mongoPrismaService.channel.create({
|
|
|
- data: {
|
|
|
- channelId: dto.channelId,
|
|
|
- name: dto.name,
|
|
|
- landingUrl: dto.landingUrl,
|
|
|
- videoCdn: this.trimOptional(dto.videoCdn) ?? null,
|
|
|
- coverCdn: this.trimOptional(dto.coverCdn) ?? null,
|
|
|
- clientName: this.trimOptional(dto.clientName) ?? null,
|
|
|
- clientNotice: this.trimOptional(dto.clientNotice) ?? null,
|
|
|
- remark: this.trimOptional(dto.remark) ?? null,
|
|
|
- isDefault,
|
|
|
- categories: categories.length ? categories : null,
|
|
|
- tags: tags.length ? tags : null,
|
|
|
- tagNames: tagNames,
|
|
|
- createAt: now,
|
|
|
- updateAt: now,
|
|
|
- },
|
|
|
- });
|
|
|
+ const isDefault = (await this.mongoPrismaService.channel.count()) === 0;
|
|
|
+ const now = this.now();
|
|
|
+
|
|
|
+ // Generate and create with retry to avoid duplicate channelNo under concurrency
|
|
|
+ let nextNo = await this.generateNextChannelNo();
|
|
|
+ let channel: Awaited<
|
|
|
+ ReturnType<typeof this.mongoPrismaService.channel.create>
|
|
|
+ > | null = null;
|
|
|
+
|
|
|
+ while (!channel) {
|
|
|
+ try {
|
|
|
+ channel = await this.mongoPrismaService.channel.create({
|
|
|
+ data: {
|
|
|
+ channelNo: nextNo,
|
|
|
+ channelId: String(nextNo), // channelId mirrors channelNo on create
|
|
|
+ name: dto.name,
|
|
|
+ landingUrl: dto.landingUrl,
|
|
|
+ videoCdn: this.trimOptional(dto.videoCdn) ?? null,
|
|
|
+ coverCdn: this.trimOptional(dto.coverCdn) ?? null,
|
|
|
+ clientName: this.trimOptional(dto.clientName) ?? null,
|
|
|
+ clientNotice: this.trimOptional(dto.clientNotice) ?? null,
|
|
|
+ remark: this.trimOptional(dto.remark) ?? null,
|
|
|
+ isDefault,
|
|
|
+ categories: categories.length ? categories : null,
|
|
|
+ tags: tags.length ? tags : null,
|
|
|
+ tagNames,
|
|
|
+ createAt: now,
|
|
|
+ updateAt: now,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ } catch (e: any) {
|
|
|
+ const isP2002 =
|
|
|
+ e?.code === 'P2002' ||
|
|
|
+ (e instanceof PrismaClientKnownRequestError && e.code === 'P2002');
|
|
|
+
|
|
|
+ if (isP2002) {
|
|
|
+ this.logger.warn(`Duplicate channelNo ${nextNo}, retrying...`);
|
|
|
+ nextNo = await this.generateNextChannelNo();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
// Auto-schedule cache refresh
|
|
|
await this.cacheSyncService.scheduleChannelRefreshAll();
|
|
|
@@ -150,20 +239,6 @@ export class ChannelService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // check for duplicate channelId (excluding current channel)
|
|
|
- const duplicateChannelId = await this.mongoPrismaService.channel.findFirst({
|
|
|
- where: {
|
|
|
- channelId: dto.channelId,
|
|
|
- id: { not: dto.id },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- if (duplicateChannelId) {
|
|
|
- throw new BadRequestException(
|
|
|
- `Channel with channelId "${dto.channelId}" already exists`,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
const now = this.now();
|
|
|
const categoryIds = dto.categories?.map((c) => c.id) ?? [];
|
|
|
const tagIds = dto.tags?.map((t) => t.id) ?? [];
|
|
|
@@ -178,7 +253,6 @@ export class ChannelService {
|
|
|
const channel = await this.mongoPrismaService.channel.update({
|
|
|
where: { id: dto.id },
|
|
|
data: {
|
|
|
- channelId: dto.channelId,
|
|
|
name: dto.name,
|
|
|
landingUrl: dto.landingUrl,
|
|
|
videoCdn: this.trimOptional(dto.videoCdn) ?? null,
|
|
|
@@ -227,6 +301,9 @@ export class ChannelService {
|
|
|
}
|
|
|
|
|
|
async list(dto: ListChannelDto) {
|
|
|
+ await this.backfillIds().catch((e) => {
|
|
|
+ this.logger.error('Error during backfillIds:', e);
|
|
|
+ });
|
|
|
const where: any = {};
|
|
|
|
|
|
if (dto.name) {
|