Files
doodle-store/apps/server/src/worksheets/worksheet.service.ts
T

261 lines
10 KiB
TypeScript

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<WorksheetPage> {
const query = parseWorksheetQuery(input);
return this.readPage(query, visibleWorksheet, (row) =>
this.toPublic(row),
);
}
/** 仅管理控制器调用;公开 DTO 与管理 DTO 分离,不能由 query 提升权限。 */
async listForAdmin(
input: AdminWorksheetQueryDto,
): Promise<AdminWorksheetPage> {
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<T extends PublicWorksheet>(
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<Array<{ total: bigint }>>`
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<WorksheetRow[]>(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<PublicWorksheet> {
return this.toPublic(await this.readOne(id, visibleWorksheet));
}
/** 管理预览只增加发布状态和有效可见性,不泄漏源文件或任意渲染配置。 */
async getForAdmin(id: string): Promise<AdminWorksheet> {
return this.toAdmin(await this.readOne(id, Prisma.sql`TRUE`));
}
private async readOne(
id: string,
visibility: Prisma.Sql,
): Promise<WorksheetRow> {
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,
};
}
}