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( 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(); 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>, 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); } }