feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接
新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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'];
|
||||
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;
|
||||
@@ -13,10 +39,194 @@ function pickContentTranslation<T extends { locale: string }>(
|
||||
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({
|
||||
@@ -30,39 +240,208 @@ export class ContentService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return { ...item, translation: t };
|
||||
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;
|
||||
linkType?: string;
|
||||
linkTarget?: string;
|
||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
||||
status?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
translations: ContentTranslationInput[];
|
||||
}) {
|
||||
return this.prisma.content.create({
|
||||
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: data.contentType,
|
||||
contentType,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
linkType: data.linkType,
|
||||
linkTarget: data.linkTarget,
|
||||
status: 'ACTIVE',
|
||||
status,
|
||||
linkType: data.linkType?.trim() || null,
|
||||
linkTarget: data.linkTarget?.trim() || null,
|
||||
startTime,
|
||||
endTime,
|
||||
translations: {
|
||||
create: data.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 listAll(contentType?: string) {
|
||||
return this.prisma.content.findMany({
|
||||
where: contentType ? { contentType } : {},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user