feat: 完成分类、worksheet 接口开发,资源迁移到七牛

This commit is contained in:
R524809
2026-10-16 17:32:23 +08:00
parent e6ded00a4c
commit 8827110f2f
107 changed files with 12775 additions and 125 deletions
@@ -0,0 +1,207 @@
import { BadRequestException } from '@nestjs/common';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type { WorksheetAgeBand, WorksheetSort } from '@doodle/contracts';
export const worksheetAgeBands = {
'3-4': [3, 4],
'4-5': [4, 5],
'5-6': [5, 6],
'6-7': [6, 7],
'7-8': [7, 8],
} as const;
/** HTTP query 先做字段白名单,随后显式解析;不把 false、空串或重复参数隐式转换。 */
export class WorksheetQueryDto {
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 1,
minimum: 1,
maximum: 1000000,
})
declare page?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 20,
minimum: 1,
maximum: 100,
})
declare pageSize?: string;
@Allow()
@ApiPropertyOptional({
example: 'math',
description: '精确分类 ID,不自动包含子分类',
})
declare categoryId?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
minimum: -4,
maximum: 6,
description: '经审核的适用年级,不能与 grades 同传',
})
declare grade?: string;
@Allow()
@ApiPropertyOptional({
example: '-1,0,1',
description: '多个年级,逗号分隔,任一匹配;不重复返回题型',
})
declare grades?: string;
@Allow()
@ApiPropertyOptional({
enum: Object.keys(worksheetAgeBands),
description:
'题型适用范围完整覆盖所选年龄段:ageMin ≤ 下限,ageMax ≥ 上限',
})
declare ageBand?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
minimum: 0,
maximum: 32767,
description: '单个年龄须被题型年龄范围覆盖,不能与 ageBand 同传',
})
declare age?: string;
@Allow()
@ApiPropertyOptional({
example: '描红',
maxLength: 100,
description: 'Tag 精确匹配,区分大小写',
})
declare tag?: string;
@Allow()
@ApiPropertyOptional({
enum: ['default', 'latest', 'hot'],
default: 'default',
})
declare sort?: string;
@Allow()
@ApiPropertyOptional({
type: Boolean,
description: '筛选人工运营热门标记,可与热度排序分别使用',
})
declare isHot?: string;
@Allow()
@ApiPropertyOptional({
type: Boolean,
description: '按真实首次发布时间及新品窗口筛选,未知发布时间不标新品',
})
declare isNew?: string;
}
export interface ParsedWorksheetQuery {
page: number;
pageSize: number;
categoryId: string | undefined;
grades: number[] | undefined;
ageBand: WorksheetAgeBand | undefined;
age: number | undefined;
tag: string | undefined;
sort: WorksheetSort;
isHot: boolean | undefined;
isNew: boolean | undefined;
}
/** service 也调用同一解析入口,确保手动调用不会绕过组合和取值校验。 */
export function parseWorksheetQuery(
input: WorksheetQueryDto,
): ParsedWorksheetQuery {
const allowed = [
'page',
'pageSize',
'categoryId',
'grade',
'grades',
'ageBand',
'age',
'tag',
'sort',
'isHot',
'isNew',
];
if (Object.keys(input).some((key) => !allowed.includes(key)))
throw new BadRequestException('未知题型查询参数');
const text = (
value: unknown,
name: string,
max: number,
): string | undefined => {
if (value === undefined) return undefined;
if (
typeof value !== 'string' ||
!value.trim() ||
[...value].length > max ||
value.includes('\0')
)
throw new BadRequestException(`${name} 不合法`);
return value.trim();
};
const integer = (
value: unknown,
name: string,
min: number,
max: number,
): number | undefined => {
const raw = text(value, name, 16);
if (raw === undefined) return undefined;
if (!/^(?:0|-?[1-9]\d*)$/.test(raw))
throw new BadRequestException(`${name} 须为整数`);
const result = Number(raw);
if (!Number.isSafeInteger(result) || result < min || result > max)
throw new BadRequestException(`${name} 越界`);
return result;
};
const boolean = (value: unknown, name: string): boolean | undefined => {
const raw = text(value, name, 5);
if (raw === undefined) return undefined;
if (raw !== 'true' && raw !== 'false')
throw new BadRequestException(`${name} 须为 true 或 false`);
return raw === 'true';
};
const grade = integer(input.grade, 'grade', -4, 6);
const gradeList = text(input.grades, 'grades', 100);
if (grade !== undefined && gradeList !== undefined)
throw new BadRequestException('grade 和 grades 不能同时传入');
let grades = grade === undefined ? undefined : [grade];
if (gradeList !== undefined) {
const values = gradeList.split(',');
if (values.length > 11)
throw new BadRequestException('grades 最多 11 项');
grades = [
...new Set(values.map((value) => integer(value, 'grades', -4, 6)!)),
];
}
const ageBand = text(input.ageBand, 'ageBand', 3);
if (ageBand !== undefined && !Object.hasOwn(worksheetAgeBands, ageBand))
throw new BadRequestException('年龄段不合法');
const age = integer(input.age, 'age', 0, 32767);
if (age !== undefined && ageBand !== undefined)
throw new BadRequestException('age 与 ageBand 不能同时传入');
const sort = text(input.sort, 'sort', 10) ?? 'default';
if (!['default', 'latest', 'hot'].includes(sort))
throw new BadRequestException('排序方式不合法');
return {
page: integer(input.page, 'page', 1, 1000000) ?? 1,
pageSize: integer(input.pageSize, 'pageSize', 1, 100) ?? 20,
categoryId: text(input.categoryId, 'categoryId', 128),
grades,
ageBand: ageBand as WorksheetAgeBand | undefined,
age,
tag: text(input.tag, 'tag', 100),
sort: sort as WorksheetSort,
isHot: boolean(input.isHot, 'isHot'),
isNew: boolean(input.isNew, 'isNew'),
};
}
@@ -0,0 +1,62 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type {
ApiSuccess,
PublicWorksheet,
WorksheetPage,
} from '@doodle/contracts';
import { Public } from '../auth/auth.decorators';
import { WorksheetQueryDto } from './worksheet-query.dto';
import { WorksheetService } from './worksheet.service';
@Controller('worksheets')
@ApiTags('worksheets')
@Public()
/** 小程序与未来 Web 共用的公开只读接口,不提供管理状态或原始统计的查询开关。 */
export class WorksheetController {
constructor(private readonly worksheets: WorksheetService) {}
@Get()
@ApiOperation({
summary: '分页查询全部/分类/年级/年龄段/Tag,支持最新和热门排序',
security: [],
description:
'条件可组合,题型及全部上级分类必须 active;sort=hot 按下载展示值降序;isHot 为运营标记筛选。预览未就绪返回 null。',
})
@ApiOkResponse({
description:
'{code:0,message:"ok",data:{items,total,page,pageSize,hasNext}};downloads/likes 为含 seed 的十进制字符串。',
})
async list(
@Query() query: WorksheetQueryDto,
): Promise<ApiSuccess<WorksheetPage>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.list(query),
};
}
@Get(':id')
@ApiOperation({
summary: '公开题型详情;不存在或未发布返回 404',
security: [],
})
@ApiParam({
name: 'id',
example: 'number-find',
description: '使用题型列表 data.items 中的 id,例如 number-find',
})
@ApiNotFoundResponse({
description: '题型不存在,或题型及所属分类链尚未全部上线',
})
async get(@Param('id') id: string): Promise<ApiSuccess<PublicWorksheet>> {
return { code: 0, message: 'ok', data: await this.worksheets.get(id) };
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import {
AdminWorksheetController,
AdminResourceController,
} from '../content/content.controller';
import { PublicationService } from '../content/publication.service';
import { ResourcePreviewService } from '../content/resource-preview.service';
import { WorksheetController } from './worksheet.controller';
import { WorksheetService } from './worksheet.service';
/** 公开题型查询与独立鉴权的管理预览、上下架;不注册文件上传或资源公开查询。 */
@Module({
imports: [DatabaseModule],
controllers: [
WorksheetController,
AdminWorksheetController,
AdminResourceController,
],
providers: [WorksheetService, PublicationService, ResourcePreviewService],
})
export class WorksheetModule {}
@@ -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,
};
}
}