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