261 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|