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
+3 -1
View File
@@ -1,10 +1,12 @@
import { Module, type DynamicModule } from '@nestjs/common';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { CategoryModule } from './categories/category.module';
import { WorksheetModule } from './worksheets/worksheet.module';
import { ServerConfigModule, type ServerConfig } from './config/server-config';
@Module({
imports: [AuthModule],
imports: [AuthModule, CategoryModule, WorksheetModule],
controllers: [HealthController],
})
export class AppModule {
+7 -2
View File
@@ -33,6 +33,9 @@ export function configureApplication(
const startedAt = performance.now();
request.requestId = requestId;
response.setHeader('X-Request-Id', requestId);
// 登录和本人接口的成功/失败均禁止缓存,避免凭证或个人数据进入公共缓存。
if (/^\/api\/v1\/(auth|me)(?:\/|$)/.test(request.path))
response.setHeader('Cache-Control', 'no-store');
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
response.once('finish', () =>
logger.log('http_request_completed', {
@@ -57,7 +60,7 @@ export function configureApplication(
credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
exposedHeaders: ['X-Request-Id'],
exposedHeaders: ['X-Request-Id', 'Retry-After'],
});
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
@@ -84,7 +87,9 @@ export function configureApplication(
if (config.swaggerEnabled) {
const options = new DocumentBuilder()
.setTitle('Doodle Store API')
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入')
.setDescription(
'统一业务 API;已接入微信静默登录、本人资料、账户鉴权与分类查询/管理',
)
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
+10 -11
View File
@@ -2,7 +2,6 @@ import {
ForbiddenException,
Inject,
Injectable,
UnauthorizedException,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
@@ -11,6 +10,8 @@ import type { HttpRequest } from '../common/http-request';
import { ACCOUNT_READER, type AccountReader } from './account-reader';
import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators';
import { JwtTokenService } from './jwt-token.service';
import { ApiException } from '../common/api-exception';
import { requireCurrentAccount } from './account-policy';
@Injectable()
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
@@ -35,19 +36,17 @@ export class AccessTokenGuard implements CanActivate {
: null;
const token = match?.[1];
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。
if (!token || token.length > 8192) throw new UnauthorizedException();
if (!authorization) throw new ApiException(401, 'AUTH_REQUIRED');
if (!token || token.length > 8192)
throw new ApiException(401, 'AUTH_INVALID');
const claims = await this.tokens.verifyAccessToken(token);
// 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。
// 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。
const account = await this.accounts.findById(claims.sub);
if (
!account ||
account.id !== claims.sub ||
account.status !== 'active' ||
account.authVersion !== claims.authVersion
) {
throw new UnauthorizedException();
}
const account = requireCurrentAccount(
await this.accounts.findById(claims.sub),
claims.sub,
claims.authVersion,
);
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
request.auth = {
userId: account.id,
+17
View File
@@ -0,0 +1,17 @@
import type { Account } from './account-reader';
import { ApiException } from '../common/api-exception';
/** 请求身份必须与当前账户一致;读后再变更的事务需要再次调用本校验。 */
export function requireCurrentAccount(
account: Account | null,
userId: string,
authVersion: number,
): Account {
if (!account || account.id !== userId)
throw new ApiException(401, 'ACCOUNT_NOT_FOUND');
if (account.status !== 'active')
throw new ApiException(401, 'ACCOUNT_DISABLED');
if (account.authVersion !== authVersion)
throw new ApiException(401, 'AUTH_REVOKED');
return account;
}
+1 -12
View File
@@ -1,6 +1,4 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
/** 用户模块通过此注入标识替换账户查询实现,JWT 模块不直接依赖数据库模型。 */
/** 账户读取注入标识,JWT 守卫不直接依赖数据库模型。 */
export const ACCOUNT_READER = Symbol('ACCOUNT_READER');
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
export interface Account {
@@ -13,12 +11,3 @@ export interface Account {
export interface AccountReader {
findById(userId: string): Promise<Account | null>;
}
@Injectable()
/** Prisma 账户查询接入前的占位实现:返回 503,确保受保护接口不会被提前放行。 */
export class UnconfiguredAccountReader implements AccountReader {
async findById(): Promise<Account | null> {
// 后续 Prisma 用户模块替换此 provider;不能仅凭 JWT 放行账户数据接口。
throw new ServiceUnavailableException();
}
}
+152
View File
@@ -0,0 +1,152 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import type {
CurrentUserProfile,
UserSummary,
WechatLoginMode,
} from '@doodle/contracts';
import type { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
import type { Account, AccountReader } from './account-reader';
import type { WechatIdentity } from './wechat-client';
import { requireCurrentAccount } from './account-policy';
import type { AuthenticatedUser } from '../common/http-request';
const accountSelect = {
id: true,
status: true,
role: true,
authVersion: true,
} as const;
const profileSelect = {
...accountSelect,
unionid: true,
nickName: true,
createdAt: true,
} as const;
type StoredAccount = Prisma.UserGetPayload<{ select: typeof accountSelect }>;
type StoredProfile = Prisma.UserGetPayload<{ select: typeof profileSelect }>;
@Injectable()
/** OpenID 唯一约束保证只建一人;账户行锁串行化登录与状态/资料变更。 */
export class AccountRepository implements AccountReader {
private readonly logger = new Logger(AccountRepository.name);
constructor(private readonly database: PrismaService) {}
async findById(userId: string): Promise<Account | null> {
try {
const user = await this.database.client.user.findUnique({
where: { id: userId },
select: accountSelect,
});
return user ? this.toAccount(user) : null;
} catch {
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
/** 先由调用方完成微信兑换;事务内没有外部 HTTP,普通登录不撤销其他设备凭证。 */
async login(
identity: WechatIdentity,
mode: WechatLoginMode,
): Promise<{ account: Account; user: UserSummary }> {
try {
return await this.database.client.$transaction(
async (tx) => {
if (mode === 'start') {
// 显式 DO NOTHING,不让并发首次登录覆盖既有账户的角色、禁用状态或资料。
await tx.$executeRaw`
INSERT INTO public.users (id, openid, unionid)
VALUES (${randomUUID()}, ${identity.openid}, ${identity.unionid})
ON CONFLICT (openid) DO NOTHING
`;
}
const rows = await tx.$queryRaw<Array<{ id: string }>>`
SELECT id FROM public.users WHERE openid = ${identity.openid} FOR UPDATE
`;
const row = rows[0];
if (!row) throw new ApiException(401, 'ACCOUNT_NOT_FOUND');
const stored = await tx.user.findUniqueOrThrow({
where: { id: row.id },
select: profileSelect,
});
const account = this.toAccount(stored);
if (account.status !== 'active')
throw new ApiException(401, 'ACCOUNT_DISABLED');
const now = new Date();
const addUnionId =
!stored.unionid && identity.unionid !== null;
if (
stored.unionid &&
identity.unionid &&
stored.unionid !== identity.unionid
)
this.logger.warn('wechat_unionid_mismatch');
await tx.user.update({
where: { id: row.id },
data: {
lastActiveAt: now,
...(addUnionId
? { unionid: identity.unionid, updatedAt: now }
: {}),
},
select: { id: true },
});
return { account, user: this.toSummary(stored) };
},
{ maxWait: 5000, timeout: 10_000 },
);
} catch (error) {
if (error instanceof ApiException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
/** 再读一次当前账户,防止守卫通过后资料查询之间的注销/版本变化被忽略。 */
async getProfile(identity: AuthenticatedUser): Promise<CurrentUserProfile> {
try {
const user = await this.database.client.user.findUnique({
where: { id: identity.userId },
select: profileSelect,
});
const account = requireCurrentAccount(
user ? this.toAccount(user) : null,
identity.userId,
identity.authVersion,
);
return { ...this.toSummary(user!), role: account.role };
} catch (error) {
if (error instanceof ApiException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
private toAccount(user: StoredAccount): Account {
if (
!['user', 'admin'].includes(user.role) ||
!['active', 'disabled'].includes(user.status) ||
!Number.isSafeInteger(user.authVersion) ||
user.authVersion < 0
)
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
return {
id: user.id,
authVersion: user.authVersion,
role: user.role as Account['role'],
status: user.status as Account['status'],
};
}
private toSummary(user: StoredProfile): UserSummary {
// 七牛签名尚未接入,明确返回空头像,不下发对象 key、私有地址或任意外链。
return {
id: user.id,
nickName: user.nickName,
avatarUrl: null,
avatarUrlExpiresAt: null,
createdAt: user.createdAt.toISOString(),
};
}
}
+17 -5
View File
@@ -1,21 +1,33 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ACCOUNT_READER, UnconfiguredAccountReader } from './account-reader';
import { ACCOUNT_READER } from './account-reader';
import { AccessTokenGuard } from './access-token.guard';
import { JwtTokenService } from './jwt-token.service';
import { DatabaseModule } from '../database/database.module';
import { AccountRepository } from './account.repository';
import { WECHAT_FETCH, WechatClient } from './wechat-client';
import { LoginRateLimiter } from './login-rate-limiter';
import { LoginService } from './login.service';
import { LoginController } from './login.controller';
import { CurrentUserController } from './current-user.controller';
@Module({
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
imports: [JwtModule.register({})],
imports: [JwtModule.register({}), DatabaseModule],
controllers: [LoginController, CurrentUserController],
providers: [
JwtTokenService,
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader },
AccountRepository,
{ provide: ACCOUNT_READER, useExisting: AccountRepository },
{ provide: WECHAT_FETCH, useValue: fetch },
WechatClient,
LoginRateLimiter,
LoginService,
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
{ provide: APP_GUARD, useClass: AccessTokenGuard },
],
exports: [JwtTokenService, ACCOUNT_READER],
})
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */
/** 微信静默登录、本人读取与全局鉴权;身份始终来自可信微信兑换或本站凭证。 */
export class AuthModule {}
@@ -0,0 +1,25 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, CurrentUserProfile } from '@doodle/contracts';
import { CurrentUser } from './auth.decorators';
import type { AuthenticatedUser } from '../common/http-request';
import { AccountRepository } from './account.repository';
@Controller('me')
@ApiTags('users')
export class CurrentUserController {
constructor(private readonly accounts: AccountRepository) {}
@Get()
@ApiOperation({ summary: '获取当前有效账户的本人资料' })
/** 仅从守卫写入的身份读取本人,拒绝由 query/body 指定其他账户。 */
async getMe(
@CurrentUser() user: AuthenticatedUser,
): Promise<ApiSuccess<CurrentUserProfile>> {
return {
code: 0,
message: 'ok',
data: await this.accounts.getProfile(user),
};
}
}
+26 -4
View File
@@ -1,6 +1,8 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import type { WechatLoginResult } from '@doodle/contracts';
import { ApiException } from '../common/api-exception';
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
export interface AccessTokenClaims {
@@ -43,6 +45,21 @@ export class JwtTokenService {
);
}
/** 响应期限从已签发的实际 claims 取得,避免两个时钟计算出不一致的有效期。 */
async issueAccessToken(
userId: string,
authVersion: number,
): Promise<Omit<WechatLoginResult, 'user'>> {
const accessToken = await this.signAccessToken(userId, authVersion);
const claims = await this.verifyAccessToken(accessToken);
return {
accessToken,
tokenType: 'Bearer',
expiresIn: claims.exp - claims.iat,
expiresAt: new Date(claims.exp * 1000).toISOString(),
};
}
/** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */
async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
try {
@@ -70,9 +87,14 @@ export class JwtTokenService {
throw new Error('Invalid access token claims');
}
return claims;
} catch {
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。
throw new UnauthorizedException();
} catch (error) {
// 只公开客户端恢复需要的有限原因,不泄漏验签细节、token 或原始异常。
throw new ApiException(
401,
error instanceof Error && error.name === 'TokenExpiredError'
? 'AUTH_EXPIRED'
: 'AUTH_INVALID',
);
}
}
}
@@ -0,0 +1,51 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { ApiException } from '../common/api-exception';
@Injectable()
/** 单实例固定窗口限流;有界存储,容量耗尽拒绝新来源而不驱逐仍有效的窗口。 */
export class LoginRateLimiter {
private readonly windows = new Map<
string,
{ count: number; resetAt: number }
>();
private readonly windowMs = 60_000;
private readonly maximumKeys = 10_000;
checkIp(ip: string): void {
this.consume(`ip:${ip}`, 30);
}
checkIdentity(openid: string): void {
// 限流键不保存原始微信身份;同一身份最多每分钟恢复 10 次。
this.consume(
`identity:${createHash('sha256').update(openid).digest('hex')}`,
10,
);
}
private consume(key: string, limit: number): void {
const now = Date.now();
let entry = this.windows.get(key);
if (!entry || entry.resetAt <= now) {
if (this.windows.size >= this.maximumKeys) {
for (const [candidate, value] of this.windows)
if (value.resetAt <= now) this.windows.delete(candidate);
if (
this.windows.size >= this.maximumKeys &&
!this.windows.has(key)
)
throw new ApiException(429, 'RATE_LIMITED', 60);
}
entry = { count: 0, resetAt: now + this.windowMs };
this.windows.set(key, entry);
}
if (entry.count >= limit)
throw new ApiException(
429,
'RATE_LIMITED',
Math.max(1, Math.ceil((entry.resetAt - now) / 1000)),
);
entry.count += 1;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Body, Controller, HttpCode, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
import { Public } from './auth.decorators';
import { WechatLoginDto } from './login.dto';
import { LoginService } from './login.service';
import type { HttpRequest } from '../common/http-request';
@Controller('auth/wechat')
@ApiTags('auth')
export class LoginController {
constructor(private readonly login: LoginService) {}
@Public()
@Post('login')
@HttpCode(200)
@ApiOperation({ summary: '小程序微信静默登录', security: [] })
@ApiResponse({
status: 200,
description: '返回本站 JWT 和用户摘要,不返回微信身份或密钥',
})
@ApiResponse({ status: 401, description: '账户不存在、已禁用' })
@ApiResponse({ status: 429, description: '登录过于频繁;参见 Retry-After' })
/** IP 只取 Express 已按可信代理配置解析的来源,不使用客户端自报转发头。 */
async signIn(
@Body() input: WechatLoginDto,
@Req() request: HttpRequest,
): Promise<ApiSuccess<WechatLoginResult>> {
return {
code: 0,
message: 'ok',
data: await this.login.login(input, request.ip ?? 'unknown'),
};
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsString, Matches, MaxLength, MinLength } from 'class-validator';
import type { WechatLoginMode, WechatLoginRequest } from '@doodle/contracts';
/** 公开接口只接受微信临时 code 和创建意图,拒绝客户端自报账户和应用身份。 */
export class WechatLoginDto implements WechatLoginRequest {
@ApiProperty({
description: '本次 wx.login 返回的一次性 code',
maxLength: 512,
})
@IsString()
@MinLength(1)
@MaxLength(512)
@Matches(/^\S+$/)
code!: string;
@ApiProperty({
enum: ['start', 'resume'],
description: 'start 允许首次建号,resume 只恢复已有账户',
})
@IsIn(['start', 'resume'])
mode!: WechatLoginMode;
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import type { WechatLoginRequest, WechatLoginResult } from '@doodle/contracts';
import { WechatClient } from './wechat-client';
import { AccountRepository } from './account.repository';
import { JwtTokenService } from './jwt-token.service';
import { LoginRateLimiter } from './login-rate-limiter';
@Injectable()
export class LoginService {
constructor(
private readonly wechat: WechatClient,
private readonly accounts: AccountRepository,
private readonly tokens: JwtTokenService,
private readonly limiter: LoginRateLimiter,
) {}
/** 外部身份验证后再开启账户事务;签发发生在提交后,不对客户端提供任意身份签发能力。 */
async login(
input: WechatLoginRequest,
ip: string,
): Promise<WechatLoginResult> {
this.limiter.checkIp(ip);
const identity = await this.wechat.exchangeCode(input.code);
this.limiter.checkIdentity(identity.openid);
const { account, user } = await this.accounts.login(
identity,
input.mode,
);
return {
...(await this.tokens.issueAccessToken(
account.id,
account.authVersion,
)),
user,
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Inject, Injectable } from '@nestjs/common';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import { ApiException } from '../common/api-exception';
/** 只有微信服务端成功验证后才构造,不能从公开 DTO 直接转换。 */
export interface WechatIdentity {
openid: string;
unionid: string | null;
}
/** HTTP 边界便于测试替换;正式实现始终只请求固定微信 HTTPS 地址。 */
export const WECHAT_FETCH = Symbol('WECHAT_FETCH');
export type WechatFetch = typeof fetch;
@Injectable()
export class WechatClient {
constructor(
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
@Inject(WECHAT_FETCH) private readonly http: WechatFetch,
) {}
/** 一次 code 只兑换一次,不自动重试;超时后客户端需重新取得 code。 */
async exchangeCode(code: string): Promise<WechatIdentity> {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.search = new URLSearchParams({
appid: this.config.wechatMiniAppId,
secret: this.config.wechatMiniAppSecret,
js_code: code,
grant_type: 'authorization_code',
}).toString();
try {
const response = await this.http(url, {
method: 'GET',
redirect: 'error',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Wechat HTTP failure');
const payload: unknown = await response.json();
if (
!payload ||
typeof payload !== 'object' ||
Array.isArray(payload)
)
throw new Error('Invalid Wechat response');
const data = payload as Record<string, unknown>;
if (data.errcode !== undefined && data.errcode !== 0) {
if (data.errcode === 40029 || data.errcode === 40163)
throw new ApiException(400, 'WECHAT_CODE_INVALID');
if (data.errcode === 40226)
throw new ApiException(403, 'WECHAT_LOGIN_BLOCKED');
if (data.errcode === 45011)
throw new ApiException(429, 'RATE_LIMITED', 60);
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
}
if (
!this.isIdentifier(data.openid) ||
(data.unionid != null &&
data.unionid !== '' &&
!this.isIdentifier(data.unionid))
)
throw new Error('Invalid Wechat identity');
// session_key 仅用于微信数据加解密;本链路不使用、不保存、不下发。
return {
openid: data.openid,
unionid:
typeof data.unionid === 'string' && data.unionid
? data.unionid
: null,
};
} catch (error) {
if (error instanceof ApiException) throw error;
// 网络异常可能包含带 AppSecret 的 URL,禁止记录或传播异常原文。
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
}
}
private isIdentifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= 128 &&
!/\s/.test(value)
);
}
}
@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import {
categoryKeys,
categoryObject,
normalizeCategory,
requireCategoryKeys,
type CategoryFields,
} from './category-input';
export interface CategoryImportRow extends Omit<CategoryFields, 'status'> {
status?: CategoryFields['status'];
createdAt?: Date;
updatedAt?: Date;
}
/** 兼容 CloudBase JSON Lines 和 JSON 数组;整批校验后才能进入写入事务。 */
export function parseCategoryExport(source: string): CategoryImportRow[] {
let values: unknown[];
try {
const content = source.replace(/^\uFEFF/, '').trim();
if (!content) throw new Error('empty');
const parsed: unknown = content.startsWith('[')
? JSON.parse(content)
: content
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
if (
!Array.isArray(parsed) ||
parsed.length === 0 ||
parsed.length > 10_000
)
throw new Error('size');
values = parsed;
} catch {
throw new BadRequestException(
'分类文件须为非空 JSON 数组或 JSON Lines,最多 10000 条',
);
}
const ids = new Set<string>();
return values.map((value) => {
const row = categoryObject(value);
requireCategoryKeys(row, [
...categoryKeys,
'_id',
'createdAt',
'updatedAt',
]);
if (row._id !== undefined && row.id !== undefined && row._id !== row.id)
throw new BadRequestException('id 与 _id 不一致');
const fields = normalizeCategory({ ...row, id: row._id ?? row.id });
if (ids.has(fields.id)) throw new BadRequestException('分类 ID 重复');
ids.add(fields.id);
const time = (key: string): Date | undefined => {
const raw = row[key];
if (raw === undefined) return undefined;
// 明确时区,避免导入机器时区改变历史时间。拒绝 CloudBase 未识别的日期对象。
if (
typeof raw !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
raw,
) ||
!Number.isFinite(Date.parse(raw))
)
throw new BadRequestException('分类时间须为带时区的 ISO 时间');
return new Date(raw);
};
const createdAt = time('createdAt');
const updatedAt = time('updatedAt');
if (createdAt && updatedAt && updatedAt < createdAt)
throw new BadRequestException('分类更新时间不能早于创建时间');
// 旧导出没有状态:新记录为草稿,重导时由服务保留数据库当前状态。
const { status, ...rest } = fields;
return {
...rest,
...(row.status === undefined ? {} : { status }),
...(createdAt ? { createdAt } : {}),
...(updatedAt ? { updatedAt } : {}),
};
});
}
@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common';
import type { ContentStatus } from '@doodle/contracts';
import { contentStatus } from '../content/content-policy';
export interface CategoryFields {
status: ContentStatus;
id: string;
name: string;
icon: string;
color: string;
sortOrder: number;
parentId: string | null;
}
export const categoryKeys = [
'id',
'status',
'name',
'icon',
'color',
'sortOrder',
'parentId',
];
/** API 与导入共用严格校验;不将数字/布尔值隐式转成名称或 ID。 */
export function categoryObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BadRequestException('分类必须为对象');
return value as Record<string, unknown>;
}
export function requireCategoryKeys(
value: Record<string, unknown>,
allowed: string[],
): void {
if (Object.keys(value).some((key) => !allowed.includes(key)))
throw new BadRequestException('分类包含未知字段');
}
export function categoryId(value: unknown): string {
if (
typeof value !== 'string' ||
[...value].length > 128 ||
!/^[^\s/]+$/u.test(value) ||
[...value].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
)
)
throw new BadRequestException('分类 ID 不合法');
return value;
}
/** 文件图标需等资产迁移/签名模块完成,当前只接受文字、Emoji 和图标名。 */
export function normalizeCategory(
input: Record<string, unknown>,
): CategoryFields {
const text = (key: string, max: number): string => {
const value = input[key];
if (
typeof value !== 'string' ||
!value.trim() ||
[...value].length > max ||
value.includes('\0')
)
throw new BadRequestException(`分类 ${key} 不合法`);
return value.trim();
};
const icon = text('icon', 1024);
if (/^(?:https?:|cloud:|data:|file:|\/)/i.test(icon))
throw new BadRequestException('文件图标需通过资产模块绑定');
const sortOrder = input.sortOrder === undefined ? 0 : input.sortOrder;
if (
typeof sortOrder !== 'number' ||
!Number.isInteger(sortOrder) ||
sortOrder < -2147483648 ||
sortOrder > 2147483647
)
throw new BadRequestException('分类排序必须为 PostgreSQL integer');
const parent = input.parentId;
return {
status: contentStatus(
input.status === undefined ? 'draft' : input.status,
),
id: categoryId(input.id),
name: text('name', 100),
icon,
color: text('color', 32),
sortOrder,
parentId:
parent === undefined || parent === null || parent === ''
? null
: categoryId(parent),
};
}
/** 检查合并后的整棵树;迭代遍历避免深层导入触发调用栈溢出,并返回父先子后顺序。 */
export function categoryTreeOrder(
rows: Array<{ id: string; parentId: string | null }>,
): string[] {
const parents = new Map(rows.map((row) => [row.id, row.parentId]));
const done = new Set<string>();
const order: string[] = [];
for (const row of rows) {
let id: string | null = row.id;
const visiting = new Set<string>();
const path: string[] = [];
while (id !== null && !done.has(id)) {
if (!parents.has(id)) throw new BadRequestException('父分类不存在');
if (visiting.has(id))
throw new BadRequestException('分类不能形成循环引用');
visiting.add(id);
path.push(id);
id = parents.get(id)!;
}
for (const item of path.reverse()) {
done.add(item);
order.push(item);
}
}
return order;
}
@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, Category } from '@doodle/contracts';
import { CurrentUser, Public, Roles } from '../auth/auth.decorators';
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
import { CategoryService } from './category.service';
import { CreateCategoryDto, UpdateCategoryDto } from './category.dto';
@Controller('categories')
@ApiTags('categories')
/** 分类查询供小程序和未来 Web 共用,不要求静默登录先完成。 */
export class CategoryController {
constructor(private readonly categories: CategoryService) {}
@Public()
@Get()
@ApiOperation({
summary: '已上线分类,所有上级须已上线;按 sortOrder、id 升序',
security: [],
})
async list(): Promise<ApiSuccess<Category[]>> {
return { code: 0, message: 'ok', data: await this.categories.list() };
}
@Public()
@Get(':id')
@ApiOperation({ summary: '分类详情', security: [] })
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
return { code: 0, message: 'ok', data: await this.categories.get(id) };
}
}
@Controller('admin/categories')
@ApiTags('admin/categories')
@Roles('admin')
/** 分类新增、修改、删除统一要求管理员;权限取服务端当前账户,不相信客户端自报角色。 */
export class AdminCategoryController {
constructor(private readonly categories: CategoryService) {}
@Get()
@ApiOperation({ summary: '全部状态分类(管理员,包含草稿和下架)' })
async list(): Promise<ApiSuccess<Category[]>> {
return {
code: 0,
message: 'ok',
data: await this.categories.listForAdmin(),
};
}
@Get(':id')
@ApiOperation({ summary: '分类管理预览(管理员)' })
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.getForAdmin(id),
};
}
@Post()
@ApiOperation({ summary: '创建分类(管理员)' })
async create(
@Body() input: CreateCategoryDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.create(
input,
actor,
request.requestId!,
),
};
}
@Patch(':id')
@ApiOperation({ summary: '局部更新分类(管理员,ID 不可修改)' })
async update(
@Param('id') id: string,
@Body() input: UpdateCategoryDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.update(
id,
input,
actor,
request.requestId!,
),
};
}
@Delete(':id')
@ApiOperation({
summary: '删除分类(管理员,有子分类或题型引用时返回 409)',
})
async remove(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Pick<Category, 'id'>>> {
return {
code: 0,
message: 'ok',
data: await this.categories.remove(id, actor, request.requestId!),
};
}
}
@@ -0,0 +1,55 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type { ContentStatus, CreateCategoryRequest } from '@doodle/contracts';
/** 白名单和文档在此声明,字段及父子树校验统一由 category-input 完成,导入使用同一规则。 */
export class CreateCategoryDto implements CreateCategoryRequest {
@ApiPropertyOptional({
enum: ['draft', 'active', 'hidden'],
default: 'draft',
})
@Allow()
status?: ContentStatus;
@ApiProperty({ maxLength: 128, example: 'math' })
@Allow()
id!: string;
@ApiProperty({ maxLength: 100, example: '数感启蒙' })
@Allow()
name!: string;
@ApiProperty({
maxLength: 1024,
example: '📐',
description: '当前支持文字、Emoji 或图标名',
})
@Allow()
icon!: string;
@ApiProperty({ maxLength: 32, example: '#CDB4DB' })
@Allow()
color!: string;
@ApiPropertyOptional({ type: 'integer', default: 0 })
@Allow()
sortOrder?: number;
@ApiPropertyOptional({
type: String,
nullable: true,
description: 'null 或空串表示根分类',
})
@Allow()
parentId?: string | null;
}
/** ID 是稳定引用,更新路径只允许修改展示与父级字段。 */
export class UpdateCategoryDto extends PartialType(
OmitType(CreateCategoryDto, ['id'] as const),
) {}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import {
AdminCategoryController,
CategoryController,
} from './category.controller';
import { CategoryService } from './category.service';
/** 小程序与 Web 共享的分类模块。 */
@Module({
imports: [DatabaseModule],
controllers: [CategoryController, AdminCategoryController],
providers: [CategoryService],
})
export class CategoryModule {}
@@ -0,0 +1,430 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
HttpException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Category } from '@doodle/contracts';
import {
Prisma,
type Category as StoredCategory,
} from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
import type { AuthenticatedUser } from '../common/http-request';
import {
visibleCategoryIds,
lockContentAdmin,
invalidateContentSnapshots,
} from '../content/content-policy';
import { contentStatus } from '../content/content-policy';
import {
categoryId,
categoryKeys,
categoryObject,
categoryTreeOrder,
normalizeCategory,
requireCategoryKeys,
type CategoryFields,
} from './category-input';
import { parseCategoryExport } from './category-import';
export interface CategoryImportReport {
dryRun: boolean;
batchAt: string;
total: number;
inserted: number;
updated: number;
unchanged: number;
missingCreatedAt: number;
missingUpdatedAt: number;
changes: Array<{ id: string; action: 'insert' | 'update' | 'unchanged' }>;
}
@Injectable()
/** 分类读写与导入共用规则;分类树写操作按表锁串行化,防止并发改父级形成循环。 */
export class CategoryService {
constructor(private readonly database: PrismaService) {}
/** 公开分类必须从 active 根分类可达;管理读取另外注册受鉴权保护的路由。 */
async list(): Promise<Category[]> {
return this.publicRows();
}
private async publicRows(id?: string): Promise<Category[]> {
const filter =
id === undefined ? Prisma.sql`TRUE` : Prisma.sql`id = ${id}`;
return this.run(async () => {
const rows = await this.database.client.$queryRaw<StoredCategory[]>`
SELECT id, name, icon, icon_asset_id AS "iconAssetId", color,
sort_order AS "sortOrder", parent_id AS "parentId", status,
created_at AS "createdAt", updated_at AS "updatedAt"
FROM public.categories WHERE id IN (${visibleCategoryIds}) AND (${filter}) ORDER BY sort_order, id
`;
return rows.map((row) => this.toCategory(row));
});
}
/** 未上线、祖先未上线与不存在统一 404,不能通过已知 ID 绕过列表过滤。 */
async get(id: string): Promise<Category> {
categoryId(id);
const rows = await this.publicRows(id);
const row = rows.find((item) => item.id === id);
if (!row) throw new NotFoundException();
return row;
}
/** 仅管理路由调用;包含草稿、下架及上级分类隐藏的记录。 */
async listForAdmin(): Promise<Category[]> {
return this.run(async () =>
(
await this.database.client.category.findMany({
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
).map((row) => this.toCategory(row)),
);
}
async getForAdmin(id: string): Promise<Category> {
categoryId(id);
return this.run(async () => {
const row = await this.database.client.category.findUnique({
where: { id },
});
if (!row) throw new NotFoundException();
return this.toCategory(row);
});
}
/** 守卫后在事务内锁住账户并复核权限,成功修改与审计一起提交。 */
async create(
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<Category> {
const data = categoryObject(input);
requireCategoryKeys(data, categoryKeys);
const fields = normalizeCategory(data);
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const rows = await tx.category.findMany();
if (rows.some((row) => row.id === fields.id))
throw new ConflictException();
categoryTreeOrder([...rows, fields]);
const after = await tx.category.create({ data: fields });
if (fields.status === 'active')
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'create',
null,
after,
requestId,
actor.userId,
);
return this.toCategory(after);
},
{ timeout: 10_000 },
),
);
}
/** PATCH 省略字段不变;拒绝空修改及 ID 改名,重复提交相同值不刷新时间/审计。 */
async update(
id: string,
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<Category> {
categoryId(id);
const patch = categoryObject(input);
requireCategoryKeys(
patch,
categoryKeys.filter((key) => key !== 'id'),
);
if (!Object.keys(patch).length) throw new BadRequestException();
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const rows = await tx.category.findMany();
const before = rows.find((row) => row.id === id);
if (!before) throw new NotFoundException();
const fields = normalizeCategory({ ...before, ...patch });
categoryTreeOrder(
rows.map((row) => (row.id === id ? fields : row)),
);
if (this.sameFields(before, fields))
return this.toCategory(before);
if (before.iconAssetId && fields.icon !== before.icon)
throw new ConflictException(
'文件图标需通过资产模块修改',
);
const after = await tx.category.update({
where: { id },
data: { ...fields, updatedAt: new Date() },
});
if (
before.status !== after.status ||
before.parentId !== after.parentId
)
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'update',
before,
after,
requestId,
actor.userId,
);
return this.toCategory(after);
},
{ timeout: 10_000 },
),
);
}
/** 只删除无引用的分类;权限、分类树修改和删除审计同事务,不级联删除内容。 */
async remove(
id: string,
actor: AuthenticatedUser,
requestId: string,
): Promise<Pick<Category, 'id'>> {
categoryId(id);
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const before = await tx.category.findUnique({
where: { id },
});
if (!before) throw new NotFoundException();
const child = await tx.category.findFirst({
where: { parentId: id },
select: { id: true },
});
if (child) throw new ConflictException('分类仍有子分类');
const worksheet = await tx.worksheet.findFirst({
where: { categoryId: id },
select: { id: true },
});
if (worksheet)
throw new ConflictException('分类仍被题型引用');
// 外键是并发题型写入的最后保护;检查后新建的引用同样不能被静默删除。
await tx.category.delete({ where: { id } });
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'delete',
before,
null,
requestId,
actor.userId,
);
return { id };
},
{ timeout: 10_000 },
),
);
}
/** CLI 专用入口,不注册 HTTP 路由;dry-run 无任何写入,正式导入整批原子提交。 */
async importExport(
source: string,
dryRun: boolean,
requestId: string,
reason: string,
): Promise<CategoryImportReport> {
const input = parseCategoryExport(source);
const batch = new Date();
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await this.lockTree(tx);
const existing = await tx.category.findMany();
const beforeById = new Map(
existing.map((row) => [row.id, row]),
);
const merged = new Map(
existing.map((row) => [
row.id,
{ id: row.id, parentId: row.parentId },
]),
);
for (const row of input) merged.set(row.id, row);
const byId = new Map(input.map((row) => [row.id, row]));
const order = categoryTreeOrder([...merged.values()]);
const report: CategoryImportReport = {
dryRun,
batchAt: batch.toISOString(),
total: input.length,
inserted: 0,
updated: 0,
unchanged: 0,
missingCreatedAt: input.filter((row) => !row.createdAt)
.length,
missingUpdatedAt: input.filter((row) => !row.updatedAt)
.length,
changes: [],
};
for (const id of order) {
const row = byId.get(id);
if (!row) continue;
const before = beforeById.get(id);
const fields = normalizeCategory({
...row,
status: row.status ?? before?.status ?? 'draft',
});
if (before && this.sameFields(before, fields)) {
report.unchanged++;
report.changes.push({ id, action: 'unchanged' });
continue;
}
if (before?.iconAssetId)
throw new ConflictException(
'不能用文字导入覆盖文件图标',
);
// 重导永远保留现存 createdAt;无源时间时只对真正变更的行使用本批时间。
const createdAt =
before?.createdAt ?? row.createdAt ?? batch;
const updatedAt = new Date(
Math.max(
(row.updatedAt ?? batch).getTime(),
before?.updatedAt.getTime() ?? 0,
),
);
if (updatedAt < createdAt)
throw new BadRequestException('分类时间顺序不合法');
const action = before ? 'update' : 'insert';
report[before ? 'updated' : 'inserted']++;
report.changes.push({ id, action });
if (!dryRun) {
const after = before
? await tx.category.update({
where: { id },
data: { ...fields, updatedAt },
})
: await tx.category.create({
data: { ...fields, createdAt, updatedAt },
});
if (
before?.status !== after.status ||
before?.parentId !== after.parentId
)
await invalidateContentSnapshots(tx);
await this.audit(
tx,
action,
before ?? null,
after,
requestId,
null,
reason,
);
}
}
return report;
},
{ timeout: 30_000 },
),
);
}
private async lockTree(tx: Prisma.TransactionClient): Promise<void> {
// 此锁允许公开 SELECT;API 与导入都必须先持锁再读取整树,避免写偏差。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
}
private sameFields(row: StoredCategory, input: CategoryFields): boolean {
return categoryKeys.every(
(key) =>
row[key as keyof CategoryFields] ===
input[key as keyof CategoryFields],
);
}
private toCategory(row: StoredCategory): Category {
// 资产图标未接入签名前拒绝返回原始引用,不泄漏对象 key 或 cloud:// 地址。
if (
row.iconAssetId ||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon)
)
throw new HttpException('分类图标资产尚未接入', 503);
return {
status: contentStatus(row.status),
id: row.id,
name: row.name,
icon: row.icon,
color: row.color,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async audit(
tx: Prisma.TransactionClient,
action: string,
before: StoredCategory | null,
after: StoredCategory | null,
requestId: string,
actorUserId: string | null,
reason?: string,
): Promise<void> {
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorUserId,
actorType: actorUserId ? 'admin' : 'migration',
action: `category.${action}`,
resourceType: 'categories',
resourceId: (after ?? before)!.id,
requestId,
...(before ? { beforeData: this.auditSnapshot(before) } : {}),
...(after ? { afterData: this.auditSnapshot(after) } : {}),
...(reason ? { reason } : {}),
},
});
}
private auditSnapshot(row: StoredCategory): Prisma.InputJsonObject {
// 删除文件图标分类也能审计;保留稳定资产 ID,不记录旧地址或签名 URL。
const fileIcon =
row.iconAssetId ||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon);
return {
status: contentStatus(row.status),
id: row.id,
name: row.name,
icon: fileIcon ? null : row.icon,
iconAssetId: row.iconAssetId,
color: row.color,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async run<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof HttpException) throw error;
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
['P2002', 'P2003'].includes(error.code)
)
throw new ConflictException();
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
}
@@ -7,6 +7,7 @@ import {
import type { Response } from 'express';
import type { HttpRequest } from './http-request';
import { AppLogger } from '../logging/app-logger';
import { ApiException } from './api-exception';
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
const messages: Record<number, string> = {
@@ -57,13 +58,27 @@ export class ApiErrorFilter implements ExceptionFilter {
exception instanceof Error
? exception.name
: 'UnknownError',
...(exception instanceof ApiException
? { reason: exception.reason }
: {}),
});
}
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
if (
exception instanceof ApiException &&
exception.retryAfterSeconds !== undefined
)
response.setHeader(
'Retry-After',
String(exception.retryAfterSeconds),
);
response.status(status).json({
code: status,
message: messages[status] ?? '服务器暂时无法处理请求',
requestId: request.requestId,
...(exception instanceof ApiException
? { reason: exception.reason }
: {}),
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { HttpException } from '@nestjs/common';
import type { ApiErrorReason } from '@doodle/contracts';
/** 仅由可信业务创建,过滤器只输出已登记原因,不透传第三方异常原文。 */
export class ApiException extends HttpException {
constructor(
status: number,
readonly reason: ApiErrorReason,
readonly retryAfterSeconds?: number,
) {
super({ reason }, status);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ export interface RuntimeOptions {
export function getRuntimeOptions(
env: NodeJS.ProcessEnv = process.env,
): RuntimeOptions {
const rawPort = env.PORT ?? '3000';
const rawPort = env.PORT ?? '8800';
const port = Number(rawPort);
const host = (env.HOST ?? '127.0.0.1').trim();
+75 -1
View File
@@ -10,7 +10,7 @@ export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
export const API_PREFIX = 'api/v1';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */
/** 规范化的启动配置;仅在服务端使用,JWT 和微信密钥不得下发或记录。 */
export interface ServerConfig extends RuntimeOptions {
nodeEnv: 'development' | 'test' | 'production';
logLevel: LogLevel;
@@ -22,6 +22,13 @@ export interface ServerConfig extends RuntimeOptions {
jwtIssuer: string;
jwtAudience: string;
jwtExpiresInSeconds: number;
/** 微信凭据仅服务端持有,登录接入后两项必须完整。 */
wechatMiniAppId: string;
wechatMiniAppSecret: string;
databaseUrl: string;
/** 新品依据首次发布时间判断;公共资源域名缺失时不返回七牛对象地址。 */
worksheetNewWindowDays: number;
qiniuPublicDomain: string | null;
}
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
@@ -78,6 +85,41 @@ export function getServerConfig(
if (!jwtIssuer || !jwtAudience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty');
}
const wechatMiniAppId = (env.WECHAT_MINI_APP_ID ?? '').trim();
const wechatMiniAppSecret = (env.WECHAT_MINI_APP_SECRET ?? '').trim();
// 已接入真实登录;缺失凭据必须启动失败,错误不回显配置值。
if (!wechatMiniAppId || !wechatMiniAppSecret) {
throw new Error(
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET are required',
);
}
if (/\s/.test(wechatMiniAppId) || /\s/.test(wechatMiniAppSecret)) {
throw new Error(
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET must not contain whitespace',
);
}
const databaseUrl = env.DATABASE_URL ?? '';
try {
const url = new URL(databaseUrl);
if (
!['postgresql:', 'postgres:'].includes(url.protocol) ||
!url.hostname ||
url.pathname.length < 2 ||
url.hash ||
/\s/.test(databaseUrl)
)
throw new Error();
// 本阶段连接 public 中的既有 SQL 表,拒绝悄悄连接到其他 Prisma schema。
if (
url.searchParams.has('schema') &&
url.searchParams.get('schema') !== 'public'
)
throw new Error();
} catch {
throw new Error(
'DATABASE_URL must be a PostgreSQL connection URL using the public schema',
);
}
// 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
const corsOrigins = (env.CORS_ORIGINS ?? '')
.split(',')
@@ -113,6 +155,27 @@ export function getServerConfig(
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs',
);
}
const rawPublicDomain = (env.QINIU_PUBLIC_DOMAIN ?? '').trim();
let qiniuPublicDomain: string | null = null;
if (rawPublicDomain) {
try {
const url = new URL(rawPublicDomain);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
)
throw new Error();
qiniuPublicDomain = url.origin;
} catch {
throw new Error(
'QINIU_PUBLIC_DOMAIN must be an HTTPS origin without credentials, paths or query',
);
}
}
return {
...getRuntimeOptions(env),
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
@@ -130,6 +193,17 @@ export function getServerConfig(
jwtSecret,
jwtIssuer,
jwtAudience,
wechatMiniAppId,
wechatMiniAppSecret,
databaseUrl,
worksheetNewWindowDays: integer(
env,
'WORKSHEET_NEW_WINDOW_DAYS',
30,
1,
365,
),
qiniuPublicDomain,
jwtExpiresInSeconds: integer(
env,
'JWT_EXPIRES_IN_SECONDS',
+78
View File
@@ -0,0 +1,78 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
} from '@nestjs/common';
import type { ContentStatus } from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { requireCurrentAccount } from '../auth/account-policy';
import type { AuthenticatedUser } from '../common/http-request';
import { ApiException } from '../common/api-exception';
/** 只从 active 根节点展开;草稿祖先、失联或成环的分支均不对外可见。 */
export const visibleCategoryIds = Prisma.sql`WITH RECURSIVE visible_categories AS (
SELECT id FROM public.categories WHERE parent_id IS NULL AND status = 'active'
UNION
SELECT c.id FROM public.categories c JOIN visible_categories p ON c.parent_id = p.id
WHERE c.status = 'active'
) SELECT id FROM visible_categories`;
/** 题型列表、详情和管理预览共用规则,不能只过滤左侧分类菜单。SQL 中题型别名固定为 w。 */
export const visibleWorksheet = Prisma.sql`w.status = 'active' AND w.category_id IN (${visibleCategoryIds})`;
/** 发布状态显式校验;不接受布尔值、NULL 或客户端自行添加的测试状态。 */
export function contentStatus(value: unknown): ContentStatus {
if (value !== 'draft' && value !== 'active' && value !== 'hidden')
throw new BadRequestException('发布状态不合法');
return value;
}
/** 写事务重新核对当前账户并持锁,避免守卫之后被禁用或降权仍然提交。 */
export async function lockContentAdmin(
tx: Prisma.TransactionClient,
actor: AuthenticatedUser,
): Promise<void> {
const rows = await tx.$queryRaw<
Array<{
id: string;
status: 'active' | 'disabled';
role: 'user' | 'admin';
authVersion: number;
}>
>`SELECT id, status, role, auth_version AS "authVersion" FROM public.users WHERE id = ${actor.userId} FOR UPDATE`;
const account = requireCurrentAccount(
rows[0] ?? null,
actor.userId,
actor.authVersion,
);
if (account.role !== 'admin') throw new ForbiddenException();
}
/** 先撤销数据库展示快照与发布标记;外部已分发的静态文件须由后续发布链路撤销/重建。 */
export async function invalidateContentSnapshots(
tx: Prisma.TransactionClient,
): Promise<void> {
await tx.pageConfig.updateMany({
data: {
home: Prisma.DbNull,
category: Prisma.DbNull,
age: Prisma.DbNull,
version: { increment: 1 },
publishedVersion: null,
publishedAt: null,
updatedAt: new Date(),
},
});
}
/** 保留业务 HTTP 错误;连接/约束诊断不原样输出给调用者。 */
export async function runContentQuery<T>(
operation: () => Promise<T>,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof HttpException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
@@ -0,0 +1,140 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type {
AdminResourcePage,
AdminWorksheet,
AdminWorksheetPage,
AdminWorksheetResource,
ApiSuccess,
ContentPublication,
} from '@doodle/contracts';
import { CurrentUser, Roles } from '../auth/auth.decorators';
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
import { WorksheetService } from '../worksheets/worksheet.service';
import {
AdminResourceQueryDto,
AdminWorksheetQueryDto,
UpdateContentStatusDto,
} from './content.dto';
import { PublicationService } from './publication.service';
import { ResourcePreviewService } from './resource-preview.service';
@Controller('admin/worksheets')
@ApiTags('admin/worksheets')
@Roles('admin')
/** 管理预览使用本站 JWT 与当前管理员身份,不接受客户端自报测试身份。 */
export class AdminWorksheetController {
constructor(
private readonly worksheets: WorksheetService,
private readonly publication: PublicationService,
) {}
@Get()
@ApiOperation({ summary: '全部状态题型(管理员,支持状态及原有筛选)' })
async list(
@Query() query: AdminWorksheetQueryDto,
): Promise<ApiSuccess<AdminWorksheetPage>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.listForAdmin(query),
};
}
@Get(':id')
@ApiOperation({ summary: '题型管理预览(管理员,包含草稿及有效可见性)' })
async get(@Param('id') id: string): Promise<ApiSuccess<AdminWorksheet>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.getForAdmin(id),
};
}
@Patch(':id/status')
@ApiOperation({
summary: '题型发布、下架或转草稿(管理员);发布要求分类已上线',
})
async update(
@Param('id') id: string,
@Body() input: UpdateContentStatusDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<ContentPublication>> {
return {
code: 0,
message: 'ok',
data: await this.publication.update(
'worksheet',
id,
input,
actor,
request.requestId!,
),
};
}
}
@Controller('admin/worksheet-resources')
@ApiTags('admin/worksheet-resources')
@Roles('admin')
/** 资源公开列表尚未开放;管理预览和上下架先落实同一发布规则。 */
export class AdminResourceController {
constructor(
private readonly resources: ResourcePreviewService,
private readonly publication: PublicationService,
) {}
@Get()
@ApiOperation({
summary: '分页查询资源(管理员,支持 worksheetId/status)',
})
async list(
@Query() query: AdminResourceQueryDto,
): Promise<ApiSuccess<AdminResourcePage>> {
return {
code: 0,
message: 'ok',
data: await this.resources.list(query),
};
}
@Get(':id')
@ApiOperation({ summary: '资源管理预览(管理员,包含草稿及有效可见性)' })
async get(
@Param('id') id: string,
): Promise<ApiSuccess<AdminWorksheetResource>> {
return { code: 0, message: 'ok', data: await this.resources.get(id) };
}
@Patch(':id/status')
@ApiOperation({
summary: '资源发布、下架或转草稿(管理员);发布要求题型及分类已上线',
})
async update(
@Param('id') id: string,
@Body() input: UpdateContentStatusDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<ContentPublication>> {
return {
code: 0,
message: 'ok',
data: await this.publication.update(
'resource',
id,
input,
actor,
request.requestId!,
),
};
}
}
+100
View File
@@ -0,0 +1,100 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type {
ContentStatus,
UpdateContentStatusRequest,
} from '@doodle/contracts';
import {
WorksheetQueryDto,
parseWorksheetQuery,
} from '../worksheets/worksheet-query.dto';
import { contentStatus } from './content-policy';
/** 管理列表才接受 status;公开接口不复用此 DTO,避免开放草稿查询开关。 */
export class AdminWorksheetQueryDto extends WorksheetQueryDto {
@Allow()
@ApiPropertyOptional({
enum: ['draft', 'active', 'hidden'],
description: '省略查询全部状态',
})
declare status?: string;
}
/** 首期只提供状态写入,不开放未经白名单校验的渲染配置编辑。 */
export class UpdateContentStatusDto implements UpdateContentStatusRequest {
@Allow()
@ApiProperty({ enum: ['draft', 'active', 'hidden'] })
declare status: ContentStatus;
}
/** 资源管理列表仅支持分页、所属题型和状态,不隐式套用题型年龄/年级条件。 */
export class AdminResourceQueryDto {
@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({ maxLength: 128 })
declare worksheetId?: string;
@Allow()
@ApiPropertyOptional({ enum: ['draft', 'active', 'hidden'] })
declare status?: string;
}
/** 服务层同样检查字段白名单,防止非 HTTP 调用绕过状态参数限制。 */
export function parseAdminWorksheetQuery(input: AdminWorksheetQueryDto) {
const { status, ...query } = input;
return {
query: parseWorksheetQuery(query),
status: status === undefined ? undefined : contentStatus(status),
};
}
/** 严格解析资源分页和过滤;拒绝重复/未知字段,避免管理查询静默扩大范围。 */
export function parseAdminResourceQuery(input: AdminResourceQueryDto) {
if (
Object.keys(input).some(
(key) =>
!['page', 'pageSize', 'worksheetId', 'status'].includes(key),
)
)
throw new BadRequestException();
const { page, pageSize } = parseWorksheetQuery({
...(input.page === undefined ? {} : { page: input.page }),
...(input.pageSize === undefined ? {} : { pageSize: input.pageSize }),
});
const worksheetId = input.worksheetId;
if (
worksheetId !== undefined &&
(typeof worksheetId !== 'string' ||
!worksheetId.trim() ||
[...worksheetId].length > 128 ||
worksheetId.includes('\0'))
)
throw new BadRequestException();
return {
page,
pageSize,
worksheetId,
status:
input.status === undefined
? undefined
: contentStatus(input.status),
};
}
+53
View File
@@ -0,0 +1,53 @@
/** 仅解析已就绪的公开预览图;管理预览同样不绕过资产访问规则。 */
export interface PreviewAssetRow {
assetStatus: string;
assetVisibility: string;
assetPurpose: string;
assetOwnerId: string | null;
assetProvider: string;
assetKey: string | null;
assetExternalUrl: string | null;
assetMimeType: string | null;
}
/** 公开与管理共用安全的预览 URL 解析;尚未迁移、私有或签名地址返回空值。 */
export function previewUrl(
row: PreviewAssetRow,
qiniuPublicDomain: string | null,
): string | null {
// 先查 ready 和公共用途;未迁移/失败/私有文件明确留空,不泄漏旧 cloud:// 或内部 key。
if (
row.assetStatus !== 'ready' ||
row.assetVisibility !== 'public' ||
row.assetPurpose !== 'preview' ||
row.assetOwnerId !== null ||
!row.assetMimeType?.startsWith('image/')
)
return null;
if (row.assetProvider === 'external' && row.assetExternalUrl) {
try {
const url = new URL(row.assetExternalUrl);
return url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
? url.href
: null;
} catch {
return null;
}
}
if (row.assetProvider === 'qiniu' && row.assetKey && qiniuPublicDomain) {
const parts = row.assetKey.split('/');
if (
row.assetKey.startsWith('/') ||
row.assetKey.includes('\\') ||
/[\r\n]/.test(row.assetKey) ||
parts.some((part) => part === '.' || part === '..')
)
return null;
return `${qiniuPublicDomain}/${parts.map((part) => encodeURIComponent(part)).join('/')}`;
}
return null;
}
@@ -0,0 +1,183 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { ContentPublication } from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import type { AuthenticatedUser } from '../common/http-request';
import {
contentStatus,
invalidateContentSnapshots,
lockContentAdmin,
runContentQuery,
visibleCategoryIds,
visibleWorksheet,
} from './content-policy';
interface PublicationRow {
id: string;
status: string;
firstPublishedAt: Date | null;
updatedAt: Date;
}
@Injectable()
/** 状态、首次发布、页面快照失效与审计在同一事务提交;不进行外部上传。 */
export class PublicationService {
constructor(private readonly database: PrismaService) {}
/** 仅管理员路由调用;table 只取服务端常量,不从请求拼接 SQL 标识符。 */
async update(
target: 'worksheet' | 'resource',
id: string,
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<ContentPublication> {
if (!id || [...id].length > 128 || id.includes('\0'))
throw new NotFoundException();
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
Object.keys(input).some((key) => key !== 'status')
)
throw new BadRequestException();
const status = contentStatus((input as Record<string, unknown>).status);
const table =
target === 'worksheet'
? Prisma.sql`public.worksheets`
: Prisma.sql`public.worksheet_resources`;
return runContentQuery(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
// 与分类变更及导入采用相同锁顺序;资源发布总是先锁父功能,再锁资源。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
if (target === 'resource') {
const parents = await tx.$queryRaw<
Array<{ worksheetId: string }>
>`
SELECT worksheet_id AS "worksheetId" FROM public.worksheet_resources WHERE id = ${id}
`;
if (!parents[0]) throw new NotFoundException();
await tx.$queryRaw`SELECT id FROM public.worksheets WHERE id = ${parents[0].worksheetId} FOR UPDATE`;
}
const rows = await tx.$queryRaw<
PublicationRow[]
>(Prisma.sql`
SELECT id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
FROM ${table} WHERE id = ${id} FOR UPDATE
`);
const before = rows[0];
if (!before) throw new NotFoundException();
if (before.status === status)
return this.toPublication(before);
if (status === 'active') {
const available =
target === 'worksheet'
? await tx.$queryRaw<
Array<{ id: string }>
>`SELECT id FROM public.worksheets WHERE id = ${id} AND category_id IN (${visibleCategoryIds})`
: await tx.$queryRaw<
Array<{ id: string }>
>`SELECT r.id FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id WHERE r.id = ${id} AND (${visibleWorksheet})`;
if (!available.length)
throw new ConflictException(
'上级尚未上线,不能发布',
);
}
const now = new Date();
// 旧 active/hidden 的未知时间保持 NULL;首次从草稿发布才登记,不因重新上架重置。
// 有下架审计的旧记录即使被改回草稿,也不能把未知历史伪装成本次首发。
const history =
before.status === 'draft' &&
status === 'active' &&
!before.firstPublishedAt
? await tx.adminAuditLog.findMany({
where: {
resourceType:
target === 'worksheet'
? 'worksheets'
: 'worksheet_resources',
resourceId: id,
},
orderBy: [
{ createdAt: 'asc' },
{ id: 'asc' },
],
select: { beforeData: true, afterData: true },
})
: [];
const previousPublication =
history.some(
(entry) =>
this.snapshotStatus(entry.beforeData) ===
'active' ||
this.snapshotStatus(entry.afterData) ===
'active',
) ||
['active', 'hidden'].includes(
this.snapshotStatus(history[0]?.beforeData) ??
this.snapshotStatus(history[0]?.afterData) ??
'',
);
const firstPublishedAt =
before.firstPublishedAt ??
(before.status === 'draft' &&
status === 'active' &&
!previousPublication
? now
: null);
const after = (
await tx.$queryRaw<PublicationRow[]>(Prisma.sql`
UPDATE ${table} SET status = ${status}, first_published_at = ${firstPublishedAt}, updated_at = ${now}
WHERE id = ${id} RETURNING id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
`)
)[0]!;
await invalidateContentSnapshots(tx);
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorType: 'admin',
actorUserId: actor.userId,
action: `${target}.status`,
resourceType:
target === 'worksheet'
? 'worksheets'
: 'worksheet_resources',
resourceId: id,
requestId,
beforeData: { ...this.toPublication(before) },
afterData: { ...this.toPublication(after) },
},
});
return this.toPublication(after);
},
{ timeout: 10_000 },
),
);
}
/** 首条历史即为下架/上线时,不将旧未知发布时间伪装为本次首发。 */
private snapshotStatus(
value: Prisma.JsonValue | undefined,
): string | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value))
return undefined;
return typeof value.status === 'string' ? value.status : undefined;
}
private toPublication(row: PublicationRow): ContentPublication {
return {
id: row.id,
status: contentStatus(row.status),
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,116 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import type {
AdminResourcePage,
AdminWorksheetResource,
} from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import {
contentStatus,
runContentQuery,
visibleWorksheet,
} from './content-policy';
import {
parseAdminResourceQuery,
type AdminResourceQueryDto,
} from './content.dto';
import { previewUrl, type PreviewAssetRow } from './preview-url';
interface ResourceRow extends PreviewAssetRow {
id: string;
worksheetId: string;
title: string;
status: string;
sortOrder: number;
downloads: bigint;
firstPublishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
visible: boolean;
}
@Injectable()
/** 管理员可检查草稿资源;预览仍只解析公开资产,不直接返回参考图和参数覆盖。 */
export class ResourcePreviewService {
constructor(
private readonly database: PrismaService,
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
) {}
async list(input: AdminResourceQueryDto): Promise<AdminResourcePage> {
const { page, pageSize, worksheetId, status } =
parseAdminResourceQuery(input);
const parts = [Prisma.sql`TRUE`];
if (worksheetId !== undefined)
parts.push(Prisma.sql`r.worksheet_id = ${worksheetId}`);
if (status !== undefined) parts.push(Prisma.sql`r.status = ${status}`);
const where = Prisma.join(parts, ' AND ');
return runContentQuery(() =>
this.database.client.$transaction(
async (tx) => {
const counts = await tx.$queryRaw<
Array<{ total: bigint }>
>`SELECT count(*) AS total FROM public.worksheet_resources r WHERE ${where}`;
const total = Number(counts[0]!.total);
if (!Number.isSafeInteger(total))
throw new Error('资源总数超出安全范围');
const rows = await tx.$queryRaw<
ResourceRow[]
>`SELECT ${this.columns()}
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
JOIN public.assets a ON a.id = r.preview_asset_id WHERE ${where}
ORDER BY r.sort_order, r.id LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`;
return {
items: rows.map((row) => this.convert(row)),
total,
page,
pageSize,
hasNext: page * pageSize < total,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 10_000 },
),
);
}
async get(id: string): Promise<AdminWorksheetResource> {
if (!id || [...id].length > 128 || id.includes('\0'))
throw new NotFoundException();
return runContentQuery(async () => {
const rows = await this.database.client.$queryRaw<
ResourceRow[]
>`SELECT ${this.columns()}
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
JOIN public.assets a ON a.id = r.preview_asset_id WHERE r.id = ${id}`;
if (!rows[0]) throw new NotFoundException();
return this.convert(rows[0]);
});
}
private columns(): Prisma.Sql {
return Prisma.sql`r.id, r.worksheet_id AS "worksheetId", r.title, r.status,
r.sort_order AS "sortOrder", r.downloads, r.first_published_at AS "firstPublishedAt",
r.created_at AS "createdAt", r.updated_at AS "updatedAt",
(r.status = 'active' AND (${visibleWorksheet})) AS visible,
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 convert(row: ResourceRow): AdminWorksheetResource {
return {
id: row.id,
worksheetId: row.worksheetId,
title: row.title,
status: contentStatus(row.status),
previewImg: previewUrl(row, this.config.qiniuPublicDomain),
sortOrder: row.sortOrder,
downloads: row.downloads.toString(),
visible: row.visible,
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { ReadinessController } from './readiness.controller';
/** 数据库模块只由服务端导入,不对前端契约包公开。 */
@Module({
controllers: [ReadinessController],
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}
@@ -0,0 +1,35 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import { ApiException } from '../common/api-exception';
@Injectable()
/** 单应用单连接池,按需连接;关闭应用时释放,HTTP 存活检查不依赖数据库。 */
export class PrismaService implements OnModuleDestroy {
readonly client: PrismaClient;
constructor(@Inject(SERVER_CONFIG) config: ServerConfig) {
const adapter = new PrismaPg({
connectionString: config.databaseUrl,
max: 5,
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 10_000,
statement_timeout: 5000,
});
this.client = new PrismaClient({ adapter, log: [] });
}
/** 仅检查数据库连通性;不向公开健康响应返回连接信息或 SQL 异常。 */
async checkReady(): Promise<void> {
try {
await this.client.$queryRaw`SELECT 1`;
} catch {
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
async onModuleDestroy(): Promise<void> {
await this.client.$disconnect();
}
}
@@ -0,0 +1,25 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/auth.decorators';
import { PrismaService } from './prisma.service';
@Controller('health')
@ApiTags('health')
export class ReadinessController {
constructor(private readonly database: PrismaService) {}
@Public()
@Get('ready')
@ApiOperation({
summary: '数据库连通性检查,不代表微信或七牛可用',
security: [],
})
async ready() {
await this.database.checkReady();
return {
code: 0,
message: 'ok',
data: { status: 'ok', database: 'ok' },
};
}
}
+484
View File
@@ -0,0 +1,484 @@
import { createHash, randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
HttpException,
} from '@nestjs/common';
import { isISO8601 } from 'class-validator';
import type { Prisma, Worksheet } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
type WorksheetData = Omit<
Worksheet,
'previewAssetId' | 'tags' | 'renderConfig'
> & {
tags: string[];
renderConfig: Prisma.InputJsonObject;
};
export interface WorksheetImportRow {
data: WorksheetData;
previewSourceRef: string;
}
export interface WorksheetImportWarning {
id: string;
field: string;
reason: string;
originalValue?: number;
}
export interface ParsedWorksheets {
rows: WorksheetImportRow[];
warnings: WorksheetImportWarning[];
}
const allowedFields = [
'_id',
'title',
'subtitle',
'category',
'subcategory',
'path',
'ageMin',
'ageMax',
'grade',
'difficulty',
'previewImg',
'tags',
'isNew',
'isHot',
'sortOrder',
'downloads',
'likes',
'downloads_seed',
'likes_seed',
'status',
'createdAt',
'updatedAt',
'contentUpdatedAt',
];
const counterFields = [
'downloads',
'likes',
'downloadsSeed',
'likesSeed',
] as const;
/** 所有导入校验在写入前完成;原数据只作为数据解析,不执行内容或下载任意地址。 */
export function parseWorksheetExport(source: string): ParsedWorksheets {
let values: unknown;
try {
const text = source.replace(/^\uFEFF/, '').trim();
values = text.startsWith('[')
? JSON.parse(text)
: text
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
} catch {
throw new BadRequestException('题型文件须为 JSON 数组或 JSON Lines');
}
if (!Array.isArray(values) || !values.length || values.length > 10_000)
throw new BadRequestException('题型数量须在 1~10000 之间');
const ids = new Set<string>();
const warnings: WorksheetImportWarning[] = [];
const rows = values.map((value: unknown): WorksheetImportRow => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BadRequestException('题型必须为对象');
const row = value as Record<string, unknown>;
const unknown = Object.keys(row).filter(
(key) => !allowedFields.includes(key),
);
if (unknown.length)
throw new BadRequestException(
`题型包含未识别字段:${unknown.join(', ')}`,
);
const text = (key: string, max: number, empty = false): string => {
const input = row[key];
if (
typeof input !== 'string' ||
(!empty && !input.trim()) ||
[...input].length > max ||
input.includes('\0')
)
throw new BadRequestException(`题型 ${key} 不合法`);
return input;
};
const integer = (key: string, min: number, max: number): number => {
const input = row[key];
if (
typeof input !== 'number' ||
!Number.isSafeInteger(input) ||
input < min ||
input > max
)
throw new BadRequestException(
`题型 ${key} 须为 ${min}~${max} 的整数`,
);
return input;
};
const counter = (key: string, optional = false): bigint => {
const input = row[key];
if (input === undefined && optional) return 0n;
if (
typeof input !== 'number' ||
!Number.isSafeInteger(input) ||
input < 0
)
throw new BadRequestException(
`题型 ${key} 须为非负安全整数,禁止丢失精度`,
);
return BigInt(input);
};
const bool = (key: string): boolean => {
if (typeof row[key] !== 'boolean')
throw new BadRequestException(`题型 ${key} 须为布尔值`);
return row[key];
};
const date = (key: string, optional = false): Date | null => {
const input = row[key];
if ((input === undefined || input === null) && optional)
return null;
let raw: unknown = input;
if (input && typeof input === 'object' && !Array.isArray(input)) {
const wrapped = input as Record<string, unknown>;
if (Object.keys(wrapped).length !== 1 || !('$date' in wrapped))
throw new BadRequestException(`题型 ${key} 日期对象不合法`);
raw = wrapped.$date;
}
if (
typeof raw !== 'string' ||
!isISO8601(raw, { strict: true, strictSeparator: true }) ||
!/T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
raw,
) ||
!Number.isFinite(Date.parse(raw))
)
throw new BadRequestException(
`题型 ${key} 须为带时区的 ISO / $date 时间`,
);
return new Date(raw);
};
const id = text('_id', 128);
if (id !== id.trim() || /[\s/]/.test(id) || ids.has(id))
throw new BadRequestException('题型 ID 不合法或重复');
ids.add(id);
const categoryId = text('category', 128);
const path = text('path', 1024);
if (!path.startsWith('/') || path.startsWith('//'))
throw new BadRequestException('题型须保留小程序页面路径');
const previewSourceRef = text('previewImg', 4096);
// 当前迁移器只接收旧云存储 fileID,后续 HTTPS 需先核实来源,不能任意抓取。
if (!/^cloud:\/\/[^\s/?#]+\/[^\s?#]+$/.test(previewSourceRef))
throw new BadRequestException('题型预览须为稳定 cloud:// fileID');
const ageMin = integer('ageMin', 0, 32767);
const ageMax = integer('ageMax', ageMin, 32767);
const grade = integer('grade', -32768, 32767);
if (grade < -4 || grade > 6)
warnings.push({
id,
field: 'grade',
reason: '旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核',
originalValue: grade,
});
for (const key of ['downloads_seed', 'likes_seed'])
if (row[key] === undefined)
warnings.push({
id,
field: key,
reason: '源字段缺失,使用数据库约定默认值 0',
});
if (
!Array.isArray(row.tags) ||
row.tags.some(
(tag) => typeof tag !== 'string' || tag.includes('\0'),
)
)
throw new BadRequestException('题型 tags 须为字符串数组');
const status = text('status', 16);
if (!['draft', 'active', 'hidden'].includes(status))
throw new BadRequestException('题型状态不合法');
const createdAt = date('createdAt')!;
const updatedAt = date('updatedAt')!;
const contentUpdatedAt = date('contentUpdatedAt', true);
if (
updatedAt < createdAt ||
(contentUpdatedAt &&
(contentUpdatedAt < createdAt || contentUpdatedAt > updatedAt))
)
throw new BadRequestException('题型日期先后顺序不合法');
const subcategory =
row.subcategory === undefined ||
row.subcategory === null ||
row.subcategory === ''
? null
: text('subcategory', 100);
return {
previewSourceRef,
data: {
id,
title: text('title', 100),
subtitle: text('subtitle', 500, true),
categoryId,
subcategory,
path,
ageMin,
ageMax,
grade,
difficulty: integer('difficulty', 1, 4),
tags: row.tags as string[],
isNew: bool('isNew'),
isHot: bool('isHot'),
sortOrder: integer('sortOrder', -2147483648, 2147483647),
downloads: counter('downloads'),
likes: counter('likes'),
downloadsSeed: counter('downloads_seed', true),
likesSeed: counter('likes_seed', true),
status,
createdAt,
updatedAt,
contentUpdatedAt,
// 老功能只保留原路由;不猜测 renderer、首次发布时间、年级或完整内容资源。
generationMode: 'procedural',
rendererKey: null,
renderConfig: {},
firstPublishedAt: null,
},
};
});
return { rows, warnings };
}
/** 规范化类型后比较;JSON 对象键序不影响重复导入,计数以十进制文本对账。 */
function snapshot(value: unknown): Prisma.InputJsonValue {
if (value instanceof Date) return value.toISOString();
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value))
return value.map((item) => (item === null ? null : snapshot(item)));
if (value && typeof value === 'object')
return Object.fromEntries(
Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, child]) => [
key,
child === null ? null : snapshot(child),
]),
);
return value as string | number | boolean;
}
export interface WorksheetImportOptions {
dryRun: boolean;
metadataOnly: boolean;
requestId: string;
sourceSha256: string;
}
/** 仅供本地迁移 CLI 调用,不注册公开 HTTP;相同记录幂等,冲突旧快照禁止覆盖新写入。 */
export class WorksheetImporter {
constructor(private readonly database: PrismaService) {}
async importExport(source: string, options: WorksheetImportOptions) {
const parsed = parseWorksheetExport(source);
if (
createHash('sha256').update(source).digest('hex') !==
options.sourceSha256
)
throw new BadRequestException('源文件摘要不匹配');
try {
return await this.database.client.$transaction(
async (tx) => {
// 与分类管理保持锁序;同时阻止导入期间其他资产/题型写入改变核对结果。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
await tx.$executeRaw`LOCK TABLE public.assets, public.worksheets IN SHARE ROW EXCLUSIVE MODE`;
const categories = new Set(
(
await tx.category.findMany({ select: { id: true } })
).map((row) => row.id),
);
const missingCategories = [
...new Set(
parsed.rows.map((row) => row.data.categoryId),
),
].filter((id) => !categories.has(id));
if (missingCategories.length)
throw new BadRequestException(
`缺少分类:${missingCategories.join(', ')}`,
);
const sourceRefs = [
...new Set(
parsed.rows.map((row) => row.previewSourceRef),
),
];
const hashes = new Map(
sourceRefs.map((ref) => [
ref,
createHash('sha256').update(ref).digest('hex'),
]),
);
const assets = await tx.asset.findMany({
where: { sourceRefHash: { in: [...hashes.values()] } },
});
const assetsByHash = new Map(
assets.map((asset) => [asset.sourceRefHash!, asset]),
);
const existing = new Map(
(
await tx.worksheet.findMany({
where: {
id: {
in: parsed.rows.map(
(row) => row.data.id,
),
},
},
})
).map((row) => [row.id, row]),
);
const report = {
dryRun: options.dryRun,
mode: options.metadataOnly
? 'metadata-only'
: 'ready-previews',
batchAt: new Date().toISOString(),
total: parsed.rows.length,
inserted: 0,
updated: 0,
unchanged: 0,
isolated: 0,
assetsInserted: 0,
assetsReused: 0,
pendingPreviews: 0,
readyPreviews: 0,
readyForPublicUse: !options.metadataOnly,
gradeRelationsInserted: 0,
warnings: parsed.warnings,
totals: Object.fromEntries(
counterFields.map((key) => [
key,
parsed.rows
.reduce(
(sum, row) => sum + row.data[key],
0n,
)
.toString(),
]),
),
changes: [] as Array<{
id: string;
action: 'insert' | 'unchanged';
previewAssetId: string;
previewStatus: string;
}>,
};
for (const row of parsed.rows) {
const hash = hashes.get(row.previewSourceRef)!;
const mapped = assetsByHash.get(hash);
if (
mapped &&
(mapped.sourceRef !== row.previewSourceRef ||
mapped.purpose !== 'preview' ||
mapped.visibility !== 'public' ||
mapped.ownerUserId !== null ||
['failed', 'deleting', 'deleted'].includes(
mapped.status,
))
)
throw new ConflictException(
`题型 ${row.data.id} 的预览资产映射冲突`,
);
if (!options.metadataOnly && mapped?.status !== 'ready')
throw new ConflictException(
'预览尚未迁移;本地暂存须显式使用 --metadata-only,不能视为可发布导入',
);
const assetId = mapped?.id ?? `preview_${hash}`;
if (
!mapped &&
(await tx.asset.findUnique({
where: { id: assetId },
select: { id: true },
}))
)
throw new ConflictException('预览资产 ID 冲突');
const status = mapped?.status ?? 'pending';
if (status === 'ready') report.readyPreviews++;
else {
report.pendingPreviews++;
report.readyForPublicUse = false;
}
const data = { ...row.data, previewAssetId: assetId };
const before = existing.get(row.data.id);
if (
before &&
JSON.stringify(snapshot(before)) !==
JSON.stringify(snapshot(data))
)
throw new ConflictException(
`题型 ${row.data.id} 已存在且内容不同;禁止用旧导出覆盖当前数据`,
);
// 同一源文件可共享资产,只为首次遇到的新引用计一次新增。
if (mapped) report.assetsReused++;
else {
report.assetsInserted++;
const pending = {
id: assetId,
sourceRef: row.previewSourceRef,
sourceRefHash: hash,
purpose: 'preview',
visibility: 'public',
status: 'pending',
provider: 'qiniu',
};
if (!options.dryRun)
await tx.asset.create({ data: pending });
assetsByHash.set(hash, {
...pending,
bucket: null,
objectKey: null,
objectRefHash: null,
externalUrl: null,
ownerUserId: null,
mimeType: null,
sizeBytes: null,
checksum: null,
lastError: null,
createdAt: new Date(),
updatedAt: new Date(),
});
}
if (before) {
report.unchanged++;
} else {
report.inserted++;
if (!options.dryRun) {
await tx.worksheet.create({ data });
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorType: 'migration',
action: 'worksheet.import',
resourceType: 'worksheets',
resourceId: data.id,
requestId: options.requestId,
afterData: snapshot(data),
reason: `CloudBase sha256=${options.sourceSha256}; mode=${report.mode}; preview=${status}; legacy grade retained, new grades unreviewed`,
},
});
}
}
report.changes.push({
id: data.id,
action: before ? 'unchanged' : 'insert',
previewAssetId: assetId,
previewStatus: status,
});
}
return report;
},
{ timeout: 30_000 },
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
}
+13
View File
@@ -23,12 +23,25 @@ export async function bootstrap(): Promise<void> {
const app = await createApplication(config, logger);
try {
await app.listen(config.port, config.host);
// 用实际监听地址生成文档链接;通配监听地址替换为同协议族的本机地址。
const origin = new URL(await app.getUrl());
if (origin.hostname === '0.0.0.0') origin.hostname = '127.0.0.1';
if (origin.hostname === '[::]') origin.hostname = '[::1]';
const swaggerUrl = config.swaggerEnabled
? new URL('/api-docs', origin).href
: null;
logger.log('server_started', {
host: config.host,
port: config.port,
api_prefix: API_PREFIX,
swagger_enabled: config.swaggerEnabled,
swagger_url: swaggerUrl,
});
// 开发启动器在监听成功后接收通知;独立于日志级别,生产启动不发此通知。
if (process.env.DOODLE_DEV_LAUNCHER === '1')
process.stdout.write(
`${JSON.stringify({ event: 'doodle-dev-ready', swaggerUrl })}\n`,
);
} catch (error) {
await app.close();
throw error;
+8 -2
View File
@@ -1,5 +1,11 @@
# 业务模块预留目录
后续按迁移方案逐步加入 auth、users、worksheets、categories、favorites、downloads、content、assets 和 legacy 模块。
后续按迁移方案逐步加入 users、favorites、downloads、content、assets 和 legacy 模块;auth 继续扩展现有 `src/auth`,分类服务位于 `src/categories`、题型查询位于 `src/worksheets`,不另建第二套。
当前 `src/auth` 已提供 JWT 签发/验签、全局守卫、角色校验及账户读取接入点;真实微信登录、Prisma 账户读取和其他业务模块尚未实现。健康检查独立放在 `src/health`,不代表业务迁移已完成。基础设置见 [Server 文档](../../../../docs/v3.5/Server基础设置.md)。
当前 `src/auth` 已提供微信静默登录、GET /me、JWT、全局守卫、角色及真实账户检查;`src/database` 提供 Prisma 连接和数据库就绪检查。资料修改、注销和其他业务模块尚未实现;接口与测试边界见 [登录接入说明](../../../../docs/v3.5/服务端登录接入说明.md)。
`src/categories` 已提供公开列表/详情、管理员创建/局部更新/删除、引用保护、事务审计和 CLI 共用的幂等导入逻辑;文件图标待资产模块接入,详见 [分类接入说明](../../../../docs/v3.5/分类接口与数据导入.md)。
`src/imports/worksheet-import.ts` 仅供本地 CLI 导入旧题型:严格转换、pending 预览暂存、保留原始计数/日期、冲突保护和事务审计,详见 [题型数据导入说明](../../../../docs/v3.5/题型数据导入说明.md)。
`src/worksheets` 提供公开列表/详情、分类/审核年级/分龄/Tag 组合筛选、最新/热门排序和分页。始终只读 active;预览单独检查 ready/公开用途/图片类型,未就绪返回 null,不暴露旧云地址或内部生成配置。详见 [题型查询说明](../../../../docs/v3.5/题型查询接口说明.md)。
@@ -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,
};
}
}