import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import type { AdminWorksheet, AdminWorksheetPage, PublicWorksheet, WorksheetPage, } from '@doodle/contracts'; import { visibleWorksheet, runContentQuery, contentStatus, } from '../content/content-policy'; import { previewUrl } from '../content/preview-url'; import { parseAdminWorksheetQuery, type AdminWorksheetQueryDto, } from '../content/content.dto'; import { Prisma } from '../generated/prisma/client'; import { PrismaService } from '../database/prisma.service'; import { SERVER_CONFIG, type ServerConfig } from '../config/server-config'; import { parseWorksheetQuery, worksheetAgeBands, type ParsedWorksheetQuery, type WorksheetQueryDto, } from './worksheet-query.dto'; interface WorksheetRow { status: string; visible: boolean; id: string; title: string; subtitle: string; category: string; subcategory: string | null; path: string; ageMin: number; ageMax: number; grades: number[]; difficulty: number; tags: string[]; isNew: boolean; isHot: boolean; sortOrder: number; downloads: bigint; likes: bigint; downloadsSeed: bigint; likesSeed: bigint; createdAt: Date; updatedAt: Date; contentUpdatedAt: Date | null; firstPublishedAt: Date | null; assetStatus: string; assetVisibility: string; assetPurpose: string; assetOwnerId: string | null; assetProvider: string; assetKey: string | null; assetExternalUrl: string | null; assetMimeType: string | null; } @Injectable() /** 公开查询按整条所属关系限制可见性;管理预览由独立的管理员路由提供。 */ export class WorksheetService { constructor( private readonly database: PrismaService, @Inject(SERVER_CONFIG) private readonly config: ServerConfig, ) {} /** 公开查询不提供状态开关,固定要求题型及全部上级分类上线。 */ async list(input: WorksheetQueryDto): Promise { const query = parseWorksheetQuery(input); return this.readPage(query, visibleWorksheet, (row) => this.toPublic(row), ); } /** 仅管理控制器调用;公开 DTO 与管理 DTO 分离,不能由 query 提升权限。 */ async listForAdmin( input: AdminWorksheetQueryDto, ): Promise { const { query, status } = parseAdminWorksheetQuery(input); return this.readPage( query, status === undefined ? Prisma.sql`TRUE` : Prisma.sql`w.status = ${status}`, (row) => this.toAdmin(row), ); } /** 总量、数据和派生可见性在同一个数据库快照中读取。 */ private async readPage( query: ParsedWorksheetQuery, visibility: Prisma.Sql, convert: (row: WorksheetRow) => T, ) { const isNew = this.isNewExpression(new Date()); const where = this.conditions(query, isNew, visibility); return runContentQuery(() => this.database.client.$transaction( async (tx) => { const counts = await tx.$queryRaw>` SELECT COUNT(*) AS total FROM public.worksheets w WHERE ${where} `; const total = Number(counts[0]!.total); if (!Number.isSafeInteger(total)) throw new Error('题型总数超出安全范围'); const rows = await tx.$queryRaw(Prisma.sql` SELECT ${this.columns(isNew)} FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id WHERE ${where} ORDER BY ${this.order(query.sort)} LIMIT ${query.pageSize} OFFSET ${(query.page - 1) * query.pageSize} `); return { items: rows.map(convert), total, page: query.page, pageSize: query.pageSize, hasNext: query.page * query.pageSize < total, }; }, { isolationLevel: 'RepeatableRead', timeout: 10_000 }, ), ); } /** 未发布、祖先未发布与不存在统一 404。 */ async get(id: string): Promise { return this.toPublic(await this.readOne(id, visibleWorksheet)); } /** 管理预览只增加发布状态和有效可见性,不泄漏源文件或任意渲染配置。 */ async getForAdmin(id: string): Promise { return this.toAdmin(await this.readOne(id, Prisma.sql`TRUE`)); } private async readOne( id: string, visibility: Prisma.Sql, ): Promise { if (!id || [...id].length > 128 || id.includes('\0')) throw new NotFoundException(); return runContentQuery(async () => { const rows = await this.database.client.$queryRaw< WorksheetRow[] >(Prisma.sql` SELECT ${this.columns(this.isNewExpression(new Date()))} FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id WHERE w.id = ${id} AND (${visibility}) `); if (!rows[0]) throw new NotFoundException(); return rows[0]; }); } private toAdmin(row: WorksheetRow): AdminWorksheet { return { ...this.toPublic(row), status: contentStatus(row.status), visible: row.visible, }; } private isNewExpression(now: Date): Prisma.Sql { const cutoff = new Date( now.getTime() - this.config.worksheetNewWindowDays * 86400000, ); // NULL 首发时间和未来时间均为 false;旧 is_new 不能代替真实发布时间。 return Prisma.sql`COALESCE(w.first_published_at BETWEEN ${cutoff} AND ${now}, FALSE)`; } private conditions( query: ParsedWorksheetQuery, isNew: Prisma.Sql, visibility: Prisma.Sql, ): Prisma.Sql { const parts = [Prisma.sql`(${visibility})`]; if (query.categoryId !== undefined) parts.push(Prisma.sql`w.category_id = ${query.categoryId}`); if (query.grades !== undefined) parts.push(Prisma.sql`EXISTS ( SELECT 1 FROM public.worksheet_grades g WHERE g.worksheet_id = w.id AND g.grade IN (${Prisma.join(query.grades)}) )`); if (query.ageBand !== undefined) { const [min, max] = worksheetAgeBands[query.ageBand]; // 适用范围须完整覆盖所选年龄段;只相交不算命中,不做推荐榜补齐或去重混排。 parts.push(Prisma.sql`w.age_min <= ${min} AND w.age_max >= ${max}`); } if (query.age !== undefined) parts.push( Prisma.sql`w.age_min <= ${query.age} AND w.age_max >= ${query.age}`, ); if (query.tag !== undefined) parts.push( Prisma.sql`w.tags @> ${JSON.stringify([query.tag])}::jsonb`, ); if (query.isHot !== undefined) parts.push(Prisma.sql`w.is_hot = ${query.isHot}`); if (query.isNew !== undefined) parts.push(Prisma.sql`${isNew} = ${query.isNew}`); return Prisma.join(parts, ' AND '); } private order(sort: ParsedWorksheetQuery['sort']): Prisma.Sql { if (sort === 'latest') return Prisma.sql`COALESCE(w.first_published_at, w.created_at) DESC, w.id ASC`; // 转 numeric 避免两个 bigint 相加溢出;不在 JavaScript 中排序或先截断候选集。 if (sort === 'hot') return Prisma.sql` (w.downloads::numeric + w.downloads_seed::numeric) DESC, (w.likes::numeric + w.likes_seed::numeric) DESC, w.updated_at DESC, w.id ASC `; return Prisma.sql`w.sort_order ASC, w.updated_at DESC, w.id ASC`; } private columns(isNew: Prisma.Sql): Prisma.Sql { // 不 SELECT w.*,不把旧 grade、seed、内部资产或生成器配置混入公开响应。 return Prisma.sql` w.status, (${visibleWorksheet}) AS visible, w.id, w.title, w.subtitle, w.category_id AS category, w.subcategory, w.path, w.age_min AS "ageMin", w.age_max AS "ageMax", w.difficulty, w.tags, array_to_json(ARRAY(SELECT grade FROM public.worksheet_grades g WHERE g.worksheet_id = w.id ORDER BY grade)) AS grades, ${isNew} AS "isNew", w.is_hot AS "isHot", w.sort_order AS "sortOrder", w.downloads, w.likes, w.downloads_seed AS "downloadsSeed", w.likes_seed AS "likesSeed", w.created_at AS "createdAt", w.updated_at AS "updatedAt", w.content_updated_at AS "contentUpdatedAt", w.first_published_at AS "firstPublishedAt", a.status AS "assetStatus", a.visibility AS "assetVisibility", a.purpose AS "assetPurpose", a.owner_user_id AS "assetOwnerId", a.provider AS "assetProvider", a.object_key AS "assetKey", a.external_url AS "assetExternalUrl", a.mime_type AS "assetMimeType" `; } private toPublic(row: WorksheetRow): PublicWorksheet { return { id: row.id, title: row.title, subtitle: row.subtitle, category: row.category, subcategory: row.subcategory, path: row.path, ageMin: row.ageMin, ageMax: row.ageMax, grades: row.grades, difficulty: row.difficulty, previewImg: previewUrl(row, this.config.qiniuPublicDomain), tags: row.tags, isNew: row.isNew, isHot: row.isHot, sortOrder: row.sortOrder, downloads: (row.downloads + row.downloadsSeed).toString(), likes: (row.likes + row.likesSeed).toString(), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), contentUpdatedAt: row.contentUpdatedAt?.toISOString() ?? null, firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null, }; } }