新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||
|
||
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
|
||
export type ContentType = (typeof CONTENT_TYPES)[number];
|
||
|
||
/** 管理端合并 Tab:公告与滚动条在玩家端均为顶部跑马灯 */
|
||
export const ANNOUNCEMENT_ADMIN_TYPE = 'ANNOUNCEMENT';
|
||
export const ANNOUNCEMENT_TYPES: ContentType[] = ['NOTICE', 'TICKER'];
|
||
|
||
export const CONTENT_STATUSES = ['DRAFT', 'ACTIVE', 'INACTIVE'] as const;
|
||
export type ContentStatus = (typeof CONTENT_STATUSES)[number];
|
||
|
||
export const CONTENT_LINK_TYPES = ['ROUTE', 'URL'] as const;
|
||
export type ContentLinkType = (typeof CONTENT_LINK_TYPES)[number];
|
||
|
||
export const CONTENT_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||
|
||
export type ContentTranslationInput = {
|
||
locale: string;
|
||
title?: string;
|
||
body?: string;
|
||
imageUrl?: string;
|
||
};
|
||
|
||
function pickContentTranslation<T extends { locale: string }>(
|
||
translations: T[],
|
||
locale: string,
|
||
): T | undefined {
|
||
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||
for (const loc of chain) {
|
||
const hit = translations.find((tr) => tr.locale === loc);
|
||
if (hit) return hit;
|
||
}
|
||
return translations[0];
|
||
}
|
||
|
||
function normalizeOptionalUrl(value?: string | null) {
|
||
const v = value?.trim();
|
||
return v || null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class ContentService {
|
||
constructor(private prisma: PrismaService) {}
|
||
|
||
private assertContentType(type: string): ContentType {
|
||
if (!CONTENT_TYPES.includes(type as ContentType)) {
|
||
throw new BadRequestException(`Invalid contentType: ${type}`);
|
||
}
|
||
return type as ContentType;
|
||
}
|
||
|
||
private assertStatus(status: string): ContentStatus {
|
||
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
|
||
throw new BadRequestException(`Invalid status: ${status}`);
|
||
}
|
||
return status as ContentStatus;
|
||
}
|
||
|
||
private validateSchedule(startTime?: Date | null, endTime?: Date | null) {
|
||
if (startTime && endTime && endTime <= startTime) {
|
||
throw new BadRequestException('endTime must be after startTime');
|
||
}
|
||
}
|
||
|
||
private validateTranslations(
|
||
contentType: ContentType,
|
||
translations: ContentTranslationInput[],
|
||
status: ContentStatus,
|
||
) {
|
||
if (!translations.length) {
|
||
throw new BadRequestException('At least one translation required');
|
||
}
|
||
|
||
const locales = new Set<string>();
|
||
for (const tr of translations) {
|
||
if (!tr.locale?.trim()) {
|
||
throw new BadRequestException('Translation locale required');
|
||
}
|
||
if (locales.has(tr.locale)) {
|
||
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
|
||
}
|
||
locales.add(tr.locale);
|
||
}
|
||
|
||
if (status !== 'ACTIVE') return;
|
||
|
||
const hasUsable = translations.some((tr) => {
|
||
if (contentType === 'BANNER') {
|
||
return !!normalizeOptionalUrl(tr.imageUrl);
|
||
}
|
||
if (contentType === 'NOTICE') {
|
||
return !!(tr.title?.trim() || tr.body?.trim());
|
||
}
|
||
return !!tr.body?.trim();
|
||
});
|
||
|
||
if (!hasUsable) {
|
||
throw new BadRequestException(
|
||
contentType === 'BANNER'
|
||
? 'ACTIVE banner requires imageUrl in at least one locale'
|
||
: contentType === 'NOTICE'
|
||
? 'ACTIVE notice requires title or body in at least one locale'
|
||
: 'ACTIVE ticker requires body in at least one locale',
|
||
);
|
||
}
|
||
}
|
||
|
||
private validateLink(linkType?: string | null, linkTarget?: string | null) {
|
||
if (!linkType) return;
|
||
if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) {
|
||
throw new BadRequestException(`Invalid linkType: ${linkType}`);
|
||
}
|
||
if (!linkTarget?.trim()) {
|
||
throw new BadRequestException('linkTarget required when linkType is set');
|
||
}
|
||
}
|
||
|
||
playerVisibility(
|
||
item: {
|
||
status: string;
|
||
startTime: Date | null;
|
||
endTime: Date | null;
|
||
contentType: string;
|
||
translations: Array<{
|
||
locale: string;
|
||
title?: string | null;
|
||
body?: string | null;
|
||
imageUrl?: string | null;
|
||
}>;
|
||
},
|
||
now = new Date(),
|
||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||
if (item.status !== 'ACTIVE') {
|
||
return { playerVisible: false, playerHiddenReason: 'NOT_ACTIVE' };
|
||
}
|
||
if (item.startTime && item.startTime > now) {
|
||
return { playerVisible: false, playerHiddenReason: 'NOT_STARTED' };
|
||
}
|
||
if (item.endTime && item.endTime < now) {
|
||
return { playerVisible: false, playerHiddenReason: 'EXPIRED' };
|
||
}
|
||
|
||
const type = item.contentType as ContentType;
|
||
const ok = item.translations.some((tr) => {
|
||
if (type === 'BANNER') return !!normalizeOptionalUrl(tr.imageUrl);
|
||
if (type === 'NOTICE') return !!(tr.title?.trim() || tr.body?.trim());
|
||
return !!tr.body?.trim();
|
||
});
|
||
|
||
if (!ok) {
|
||
return { playerVisible: false, playerHiddenReason: 'INCOMPLETE' };
|
||
}
|
||
|
||
return { playerVisible: true, playerHiddenReason: null };
|
||
}
|
||
|
||
private mapAdminItem(
|
||
item: Awaited<ReturnType<typeof this.getRawById>>,
|
||
now = new Date(),
|
||
) {
|
||
const visibility = this.playerVisibility(item, now);
|
||
const preview =
|
||
pickContentTranslation(item.translations, 'zh-CN') ??
|
||
pickContentTranslation(item.translations, 'en-US');
|
||
return {
|
||
id: item.id.toString(),
|
||
contentType: item.contentType,
|
||
sortOrder: item.sortOrder,
|
||
status: item.status,
|
||
linkType: item.linkType,
|
||
linkTarget: item.linkTarget,
|
||
startTime: item.startTime?.toISOString() ?? null,
|
||
endTime: item.endTime?.toISOString() ?? null,
|
||
createdAt: item.createdAt.toISOString(),
|
||
updatedAt: item.updatedAt.toISOString(),
|
||
previewTitle: preview?.title ?? preview?.body?.slice(0, 40) ?? '',
|
||
previewImageUrl: preview?.imageUrl ?? null,
|
||
playerVisible: visibility.playerVisible,
|
||
playerHiddenReason: visibility.playerHiddenReason,
|
||
translations: item.translations.map((tr) => ({
|
||
locale: tr.locale,
|
||
title: tr.title ?? '',
|
||
body: tr.body ?? '',
|
||
imageUrl: tr.imageUrl ?? '',
|
||
})),
|
||
};
|
||
}
|
||
|
||
async getRawById(id: bigint) {
|
||
const item = await this.prisma.content.findUnique({
|
||
where: { id },
|
||
include: { translations: true },
|
||
});
|
||
if (!item) throw new NotFoundException('Content not found');
|
||
return item;
|
||
}
|
||
|
||
async listActiveAnnouncements(locale: string) {
|
||
const now = new Date();
|
||
const items = await this.prisma.content.findMany({
|
||
where: {
|
||
contentType: { in: [...ANNOUNCEMENT_TYPES] },
|
||
status: 'ACTIVE',
|
||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||
},
|
||
include: { translations: true },
|
||
orderBy: { sortOrder: 'asc' },
|
||
});
|
||
|
||
return items
|
||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||
.map((item) => {
|
||
const tr = pickContentTranslation(item.translations, locale);
|
||
return {
|
||
id: item.id.toString(),
|
||
contentType: item.contentType,
|
||
sortOrder: item.sortOrder,
|
||
translation: tr,
|
||
};
|
||
});
|
||
}
|
||
|
||
async listActive(contentType: string, locale: string) {
|
||
const now = new Date();
|
||
const items = await this.prisma.content.findMany({
|
||
where: {
|
||
contentType,
|
||
status: 'ACTIVE',
|
||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||
},
|
||
include: { translations: true },
|
||
orderBy: { sortOrder: 'asc' },
|
||
});
|
||
|
||
return items
|
||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||
.map((item) => {
|
||
const t = pickContentTranslation(item.translations, locale);
|
||
return {
|
||
id: item.id.toString(),
|
||
contentType: item.contentType,
|
||
sortOrder: item.sortOrder,
|
||
linkType: item.linkType,
|
||
linkTarget: item.linkTarget,
|
||
translation: t,
|
||
};
|
||
});
|
||
}
|
||
|
||
async listForAdmin(contentType?: string, status?: string) {
|
||
const typeWhere =
|
||
contentType === ANNOUNCEMENT_ADMIN_TYPE
|
||
? { contentType: { in: [...ANNOUNCEMENT_TYPES] } }
|
||
: contentType
|
||
? { contentType }
|
||
: {};
|
||
|
||
const items = await this.prisma.content.findMany({
|
||
where: {
|
||
...typeWhere,
|
||
...(status ? { status } : {}),
|
||
},
|
||
include: { translations: true },
|
||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||
});
|
||
return items.map((item) => this.mapAdminItem(item));
|
||
}
|
||
|
||
async getForAdmin(id: bigint) {
|
||
return this.mapAdminItem(await this.getRawById(id));
|
||
}
|
||
|
||
async create(data: {
|
||
contentType: string;
|
||
sortOrder?: number;
|
||
status?: string;
|
||
linkType?: string | null;
|
||
linkTarget?: string | null;
|
||
startTime?: string | null;
|
||
endTime?: string | null;
|
||
translations: ContentTranslationInput[];
|
||
}) {
|
||
const contentType = this.assertContentType(data.contentType);
|
||
const status = this.assertStatus(data.status ?? 'DRAFT');
|
||
this.validateLink(data.linkType, data.linkTarget);
|
||
this.validateTranslations(contentType, data.translations, status);
|
||
|
||
const startTime = data.startTime ? new Date(data.startTime) : null;
|
||
const endTime = data.endTime ? new Date(data.endTime) : null;
|
||
this.validateSchedule(startTime, endTime);
|
||
|
||
const item = await this.prisma.content.create({
|
||
data: {
|
||
contentType,
|
||
sortOrder: data.sortOrder ?? 0,
|
||
status,
|
||
linkType: data.linkType?.trim() || null,
|
||
linkTarget: data.linkTarget?.trim() || null,
|
||
startTime,
|
||
endTime,
|
||
translations: {
|
||
create: data.translations.map((tr) => ({
|
||
locale: tr.locale.trim(),
|
||
title: tr.title?.trim() || null,
|
||
body: tr.body?.trim() || null,
|
||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||
})),
|
||
},
|
||
},
|
||
include: { translations: true },
|
||
});
|
||
|
||
return this.mapAdminItem(item);
|
||
}
|
||
|
||
async update(
|
||
id: bigint,
|
||
data: {
|
||
sortOrder?: number;
|
||
status?: string;
|
||
linkType?: string | null;
|
||
linkTarget?: string | null;
|
||
startTime?: string | null;
|
||
endTime?: string | null;
|
||
translations?: ContentTranslationInput[];
|
||
},
|
||
) {
|
||
const existing = await this.getRawById(id);
|
||
const contentType = existing.contentType as ContentType;
|
||
const status = data.status
|
||
? this.assertStatus(data.status)
|
||
: (existing.status as ContentStatus);
|
||
|
||
const linkType =
|
||
data.linkType !== undefined
|
||
? data.linkType?.trim() || null
|
||
: existing.linkType;
|
||
const linkTarget =
|
||
data.linkTarget !== undefined
|
||
? data.linkTarget?.trim() || null
|
||
: existing.linkTarget;
|
||
this.validateLink(linkType, linkTarget);
|
||
|
||
const startTime =
|
||
data.startTime !== undefined
|
||
? data.startTime
|
||
? new Date(data.startTime)
|
||
: null
|
||
: existing.startTime;
|
||
const endTime =
|
||
data.endTime !== undefined
|
||
? data.endTime
|
||
? new Date(data.endTime)
|
||
: null
|
||
: existing.endTime;
|
||
this.validateSchedule(startTime, endTime);
|
||
|
||
const translations =
|
||
data.translations ??
|
||
existing.translations.map((tr) => ({
|
||
locale: tr.locale,
|
||
title: tr.title ?? undefined,
|
||
body: tr.body ?? undefined,
|
||
imageUrl: tr.imageUrl ?? undefined,
|
||
}));
|
||
this.validateTranslations(contentType, translations, status);
|
||
|
||
await this.prisma.content.update({
|
||
where: { id },
|
||
data: {
|
||
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||
status,
|
||
linkType,
|
||
linkTarget,
|
||
startTime,
|
||
endTime,
|
||
},
|
||
});
|
||
|
||
if (data.translations) {
|
||
for (const tr of data.translations) {
|
||
await this.prisma.contentTranslation.upsert({
|
||
where: {
|
||
contentId_locale: {
|
||
contentId: id,
|
||
locale: tr.locale.trim(),
|
||
},
|
||
},
|
||
create: {
|
||
contentId: id,
|
||
locale: tr.locale.trim(),
|
||
title: tr.title?.trim() || null,
|
||
body: tr.body?.trim() || null,
|
||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||
},
|
||
update: {
|
||
title: tr.title?.trim() || null,
|
||
body: tr.body?.trim() || null,
|
||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
return this.getForAdmin(id);
|
||
}
|
||
|
||
async updateStatus(id: bigint, status: string) {
|
||
const existing = await this.getRawById(id);
|
||
const next = this.assertStatus(status);
|
||
this.validateTranslations(
|
||
existing.contentType as ContentType,
|
||
existing.translations.map((tr) => ({
|
||
locale: tr.locale,
|
||
title: tr.title ?? undefined,
|
||
body: tr.body ?? undefined,
|
||
imageUrl: tr.imageUrl ?? undefined,
|
||
})),
|
||
next,
|
||
);
|
||
await this.prisma.content.update({
|
||
where: { id },
|
||
data: { status: next },
|
||
});
|
||
return this.getForAdmin(id);
|
||
}
|
||
|
||
async remove(id: bigint) {
|
||
await this.getRawById(id);
|
||
await this.prisma.contentTranslation.deleteMany({ where: { contentId: id } });
|
||
await this.prisma.content.delete({ where: { id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
/** @deprecated use listForAdmin */
|
||
async listAll(contentType?: string) {
|
||
return this.listForAdmin(contentType);
|
||
}
|
||
}
|