feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接

新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 10:25:42 +08:00
parent 27580b2479
commit f76728dc3e
21 changed files with 1966 additions and 136 deletions

View File

@@ -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);
}
}