feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user