feat: 完成现有小程序接口的全部迁移至新后端
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { SiteModule } from './site/site.module';
|
||||
import { MemberModule } from './member/member.module';
|
||||
import { AssetModule } from './assets/asset.module';
|
||||
import { Module, type DynamicModule } from '@nestjs/common';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@@ -6,7 +9,14 @@ import { WorksheetModule } from './worksheets/worksheet.module';
|
||||
import { ServerConfigModule, type ServerConfig } from './config/server-config';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, CategoryModule, WorksheetModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
CategoryModule,
|
||||
WorksheetModule,
|
||||
MemberModule,
|
||||
AssetModule,
|
||||
SiteModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser, Roles } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { UploadDto } from '../member/member.dto';
|
||||
import { AssetService } from './asset.service';
|
||||
|
||||
@Controller()
|
||||
@ApiTags('assets')
|
||||
/** Multipart 仅接收一个图片和 uploadId,鉴权守卫先于文件解析执行。 */
|
||||
export class AssetController {
|
||||
constructor(private readonly assets: AssetService) {}
|
||||
@Post('me/avatar-upload')
|
||||
@ApiOperation({ summary: '上传本人头像至七牛,返回资产 ID' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['file', 'uploadId'],
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
uploadId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 1 },
|
||||
}),
|
||||
)
|
||||
async avatar(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Body() input: UploadDto,
|
||||
@UploadedFile() file?: { buffer: Buffer },
|
||||
) {
|
||||
return this.upload(actor, 'avatar', input.uploadId, file);
|
||||
}
|
||||
@Post('admin/assets/preview-upload')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '管理员上传题型预览图至七牛,返回资产 ID' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['file', 'uploadId'],
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
uploadId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 1 },
|
||||
}),
|
||||
)
|
||||
async preview(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Body() input: UploadDto,
|
||||
@UploadedFile() file?: { buffer: Buffer },
|
||||
) {
|
||||
return this.upload(actor, 'preview', input.uploadId, file);
|
||||
}
|
||||
private async upload(
|
||||
actor: AuthenticatedUser,
|
||||
purpose: 'avatar' | 'preview',
|
||||
id: string,
|
||||
file?: { buffer: Buffer },
|
||||
) {
|
||||
if (!file) throw new BadRequestException('请选择图片');
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.assets.upload(actor, purpose, id, file.buffer),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AssetController } from './asset.controller';
|
||||
import { AssetService } from './asset.service';
|
||||
import { ASSET_STORAGE, QiniuGateway } from './qiniu.gateway';
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [AssetController],
|
||||
providers: [
|
||||
AssetService,
|
||||
{ provide: ASSET_STORAGE, useClass: QiniuGateway },
|
||||
],
|
||||
exports: [AssetService],
|
||||
})
|
||||
export class AssetModule {}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ApiException } from '../common/api-exception';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
ConflictException,
|
||||
type OnModuleInit,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { lockMember } from '../member/member-policy';
|
||||
import { lockContentAdmin, runContentQuery } from '../content/content-policy';
|
||||
import { publicObjectUrl } from '../content/public-resource-url';
|
||||
import { ASSET_STORAGE, type AssetStorage } from './qiniu.gateway';
|
||||
import { imageInput } from './image-input';
|
||||
|
||||
@Injectable()
|
||||
/** 上传先落 pending,再按摘要写七牛,最后事务确认 ready;失败可用同一 uploadId 重试。 */
|
||||
export class AssetService implements OnModuleInit, OnModuleDestroy {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private cleaning = false;
|
||||
private readonly uploadWindows = new Map<
|
||||
string,
|
||||
{ since: number; ids: Set<string> }
|
||||
>();
|
||||
/** 限制每人每小时的新增对象数;同一 uploadId 的恢复不额外计数,内存表有界。 */
|
||||
private limitUploads(userId: string, uploadId: string) {
|
||||
const now = Date.now();
|
||||
for (const [id, window] of this.uploadWindows)
|
||||
if (now - window.since >= 3600000) this.uploadWindows.delete(id);
|
||||
let window = this.uploadWindows.get(userId);
|
||||
if (!window) {
|
||||
if (this.uploadWindows.size >= 10000)
|
||||
throw new ApiException(429, 'RATE_LIMITED', 60);
|
||||
window = { since: now, ids: new Set() };
|
||||
this.uploadWindows.set(userId, window);
|
||||
}
|
||||
if (!window.ids.has(uploadId) && window.ids.size >= 30)
|
||||
throw new ApiException(429, 'RATE_LIMITED', 60);
|
||||
window.ids.add(uploadId);
|
||||
}
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
@Inject(ASSET_STORAGE) private readonly storage: AssetStorage,
|
||||
) {}
|
||||
async upload(
|
||||
actor: AuthenticatedUser,
|
||||
purpose: 'avatar' | 'preview',
|
||||
uploadId: string,
|
||||
bytes: Buffer,
|
||||
) {
|
||||
const { mimeType, extension } = imageInput(bytes);
|
||||
this.limitUploads(actor.userId, `${purpose}:${uploadId}`);
|
||||
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||
const id = `upload_${createHash('sha256')
|
||||
.update(JSON.stringify([actor.userId, purpose, uploadId]))
|
||||
.digest('hex')}`;
|
||||
const key = `${this.config.qiniuKeyPrefix}${purpose === 'avatar' ? 'user-avatars' : 'assets/previews/uploads'}/${id}.${extension}`;
|
||||
if (!this.config.qiniuBucket || !this.config.qiniuPublicDomain)
|
||||
throw new ConflictException('请先配置七牛空间与公开域名');
|
||||
const data = {
|
||||
id,
|
||||
provider: 'qiniu',
|
||||
purpose,
|
||||
bucket: this.config.qiniuBucket,
|
||||
objectKey: key,
|
||||
objectRefHash: createHash('sha256')
|
||||
.update(JSON.stringify(['qiniu', this.config.qiniuBucket, key]))
|
||||
.digest('hex'),
|
||||
ownerUserId: purpose === 'avatar' ? actor.userId : null,
|
||||
visibility: 'public',
|
||||
mimeType,
|
||||
checksum,
|
||||
sizeBytes: BigInt(bytes.length),
|
||||
status: 'pending',
|
||||
};
|
||||
await runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
if (purpose === 'preview') await lockContentAdmin(tx, actor);
|
||||
else await lockMember(tx, actor);
|
||||
const existing = await tx.asset.findUnique({ where: { id } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.checksum !== checksum ||
|
||||
existing.objectKey !== key ||
|
||||
!['pending', 'ready', 'failed'].includes(
|
||||
existing.status,
|
||||
)
|
||||
)
|
||||
throw new ConflictException('上传标识已被使用');
|
||||
} else await tx.asset.create({ data });
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await this.storage.put(key, bytes, mimeType);
|
||||
await runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
if (purpose === 'preview')
|
||||
await lockContentAdmin(tx, actor);
|
||||
else await lockMember(tx, actor);
|
||||
const row = await tx.asset.findUnique({ where: { id } });
|
||||
if (
|
||||
!row ||
|
||||
row.checksum !== checksum ||
|
||||
!['pending', 'ready', 'failed'].includes(row.status)
|
||||
)
|
||||
throw new ConflictException('上传状态已变化');
|
||||
await tx.asset.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ready',
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// 注销/清理与慢上传交错时,文件可能在首次删除后才到达七牛,重新入清理队列。
|
||||
await this.db.client.asset
|
||||
.updateMany({
|
||||
where: {
|
||||
id,
|
||||
purpose: 'avatar',
|
||||
ownerUserId: null,
|
||||
status: {
|
||||
in: ['pending', 'failed', 'deleting', 'deleted'],
|
||||
},
|
||||
},
|
||||
data: { status: 'deleting', updatedAt: new Date() },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
url: publicObjectUrl(this.config.qiniuPublicDomain, key),
|
||||
mimeType,
|
||||
sizeBytes: bytes.length,
|
||||
};
|
||||
}
|
||||
onModuleInit() {
|
||||
if (
|
||||
this.config.nodeEnv !== 'test' &&
|
||||
this.config.qiniuAk &&
|
||||
this.config.qiniuSk
|
||||
) {
|
||||
this.timer = setInterval(() => {
|
||||
void this.cleanup();
|
||||
}, 60000);
|
||||
this.timer.unref();
|
||||
}
|
||||
}
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
/** 注销后的头像通过持久 deleting 状态重试清理,外部失败不回滚已注销身份。 */
|
||||
async cleanup() {
|
||||
if (this.cleaning) return;
|
||||
this.cleaning = true;
|
||||
try {
|
||||
const rows = await this.db.client.asset.findMany({
|
||||
where: {
|
||||
status: 'deleting',
|
||||
purpose: 'avatar',
|
||||
bucket: this.config.qiniuBucket,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
for (const asset of rows) {
|
||||
if (!asset.bucket || !asset.objectKey) continue;
|
||||
if (
|
||||
await this.db.client.user.count({
|
||||
where: { avatarAssetId: asset.id },
|
||||
})
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
await this.storage.remove(asset.bucket, asset.objectKey);
|
||||
await this.db.client.asset.updateMany({
|
||||
where: { id: asset.id, status: 'deleting' },
|
||||
data: { status: 'deleted', lastError: null },
|
||||
});
|
||||
} catch {
|
||||
await this.db.client.asset.updateMany({
|
||||
where: { id: asset.id, status: 'deleting' },
|
||||
data: { lastError: 'QINIU_DELETE_FAILED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 外部/数据库不可用时保留 deleting,下次周期继续,不输出原始异常。 */
|
||||
} finally {
|
||||
this.cleaning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
/** 按字节头识别上传格式,拒绝 SVG/伪扩展名与过大像素尺寸,不信任客户端 MIME。 */
|
||||
export function imageInput(bytes: Buffer): {
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
} {
|
||||
if (
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
bytes.length < 24 ||
|
||||
bytes.length > 2 * 1024 * 1024
|
||||
)
|
||||
throw new BadRequestException('图片大小须在 2 MiB 内');
|
||||
const size = (width: number, height: number) => {
|
||||
if (
|
||||
!width ||
|
||||
!height ||
|
||||
width > 8192 ||
|
||||
height > 8192 ||
|
||||
width * height > 24000000
|
||||
)
|
||||
throw new BadRequestException('图片尺寸不合法');
|
||||
};
|
||||
if (
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
||||
bytes.subarray(12, 16).toString() === 'IHDR'
|
||||
) {
|
||||
size(bytes.readUInt32BE(16), bytes.readUInt32BE(20));
|
||||
if (
|
||||
!bytes
|
||||
.subarray(-12, -4)
|
||||
.equals(Buffer.from([0, 0, 0, 0, 73, 69, 78, 68]))
|
||||
)
|
||||
throw new BadRequestException('PNG 文件不完整');
|
||||
return { mimeType: 'image/png', extension: 'png' };
|
||||
}
|
||||
if (
|
||||
bytes[0] === 255 &&
|
||||
bytes[1] === 216 &&
|
||||
bytes[bytes.length - 2] === 255 &&
|
||||
bytes[bytes.length - 1] === 217
|
||||
) {
|
||||
let offset = 2;
|
||||
while (offset + 8 < bytes.length) {
|
||||
if (bytes[offset] !== 255) break;
|
||||
const marker = bytes[offset + 1]!;
|
||||
if (marker === 255) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
const length = bytes.readUInt16BE(offset + 2);
|
||||
if (length < 2 || offset + 2 + length > bytes.length) break;
|
||||
if (
|
||||
[
|
||||
192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206,
|
||||
207,
|
||||
].includes(marker)
|
||||
) {
|
||||
size(
|
||||
bytes.readUInt16BE(offset + 7),
|
||||
bytes.readUInt16BE(offset + 5),
|
||||
);
|
||||
return { mimeType: 'image/jpeg', extension: 'jpg' };
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('仅支持完整的 PNG/JPEG 图片');
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as qiniu from 'qiniu';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
export const ASSET_STORAGE = Symbol('ASSET_STORAGE');
|
||||
export interface AssetStorage {
|
||||
put(key: string, bytes: Buffer, mimeType: string): Promise<void>;
|
||||
remove(bucket: string, key: string): Promise<void>;
|
||||
}
|
||||
/** 与七牛对象一致的分块 ETag,用于上传后内容核对。 */
|
||||
export function bufferEtag(bytes: Buffer): string {
|
||||
const hashes: Buffer[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 4194304)
|
||||
hashes.push(
|
||||
createHash('sha1')
|
||||
.update(bytes.subarray(offset, offset + 4194304))
|
||||
.digest(),
|
||||
);
|
||||
const hash =
|
||||
bytes.length <= 4194304
|
||||
? createHash('sha1').update(bytes).digest()
|
||||
: createHash('sha1').update(Buffer.concat(hashes)).digest();
|
||||
return Buffer.concat([
|
||||
Buffer.from([bytes.length <= 4194304 ? 0x16 : 0x96]),
|
||||
hash,
|
||||
]).toString('base64url');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 后端唯一持有七牛凭据,限定已配置公开空间与明确对象 key,不返回上传凭证。 */
|
||||
export class QiniuGateway implements AssetStorage {
|
||||
constructor(@Inject(SERVER_CONFIG) private readonly config: ServerConfig) {}
|
||||
private sdk() {
|
||||
const c = this.config;
|
||||
if (!c.qiniuAk || !c.qiniuSk || !c.qiniuBucket || !c.qiniuPublicDomain)
|
||||
throw new ApiException(503, 'ASSET_UPSTREAM_UNAVAILABLE');
|
||||
const config = new qiniu.conf.Config({ useHttpsDomain: true });
|
||||
if (c.qiniuRegion)
|
||||
config.regionsProvider = qiniu.httpc.Region.fromRegionId(
|
||||
c.qiniuRegion,
|
||||
);
|
||||
const mac = new qiniu.auth.digest.Mac(c.qiniuAk, c.qiniuSk);
|
||||
return {
|
||||
config,
|
||||
mac,
|
||||
manager: new qiniu.rs.BucketManager(mac, config),
|
||||
};
|
||||
}
|
||||
/** SDK 的非 2xx 既可能返回 wrapper,也可能 reject;两种路径都显式检查。 */
|
||||
private async perform<T>(
|
||||
operation: () => Promise<qiniu.httpc.ResponseWrapper<T>>,
|
||||
allowMissing = false,
|
||||
): Promise<T | null> {
|
||||
let response: qiniu.httpc.ResponseWrapper<T>;
|
||||
try {
|
||||
response = await operation();
|
||||
} catch (error) {
|
||||
if (
|
||||
allowMissing &&
|
||||
(error as { statusCode?: number }).statusCode === 612
|
||||
)
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
const status = response.resp.statusCode || 0;
|
||||
if (allowMissing && status === 612) return null;
|
||||
if (status < 200 || status >= 300)
|
||||
throw new Error('Qiniu request failed');
|
||||
return (response.data ?? null) as T | null;
|
||||
}
|
||||
async put(key: string, bytes: Buffer, mimeType: string) {
|
||||
try {
|
||||
const { config, mac, manager } = this.sdk();
|
||||
const bucket = this.config.qiniuBucket!;
|
||||
if (
|
||||
(await this.perform(() => manager.getBucketInfo(bucket)))
|
||||
?.private !== 0
|
||||
)
|
||||
throw new Error();
|
||||
const expected = bufferEtag(bytes);
|
||||
const existing = await this.perform(
|
||||
() => manager.stat(bucket, key),
|
||||
true,
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.hash !== expected ||
|
||||
existing.fsize !== bytes.length
|
||||
)
|
||||
throw new Error();
|
||||
return;
|
||||
}
|
||||
const policy = new qiniu.rs.PutPolicy({
|
||||
scope: `${bucket}:${key}`,
|
||||
insertOnly: 1,
|
||||
expires: 300,
|
||||
fsizeLimit: bytes.length,
|
||||
});
|
||||
const extra = new qiniu.form_up.PutExtra();
|
||||
extra.mimeType = mimeType;
|
||||
extra.checkCrc = true;
|
||||
await this.perform(() =>
|
||||
new qiniu.form_up.FormUploader(config).put(
|
||||
policy.uploadToken(mac),
|
||||
key,
|
||||
bytes,
|
||||
extra,
|
||||
),
|
||||
);
|
||||
const stored = await this.perform(() => manager.stat(bucket, key));
|
||||
if (stored?.hash !== expected || stored?.fsize !== bytes.length)
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new ApiException(503, 'ASSET_UPSTREAM_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
async remove(bucket: string, key: string) {
|
||||
if (bucket !== this.config.qiniuBucket)
|
||||
throw new ApiException(503, 'ASSET_UPSTREAM_UNAVAILABLE');
|
||||
try {
|
||||
await this.perform(
|
||||
() => this.sdk().manager.delete(bucket, key),
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as { statusCode?: number }).statusCode !== 612)
|
||||
throw new ApiException(503, 'ASSET_UPSTREAM_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import type {
|
||||
CurrentUserProfile,
|
||||
UserSummary,
|
||||
@@ -10,6 +10,8 @@ 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 { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import { publicObjectUrl } from '../content/public-resource-url';
|
||||
import { requireCurrentAccount } from './account-policy';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
|
||||
@@ -24,6 +26,17 @@ const profileSelect = {
|
||||
unionid: true,
|
||||
nickName: true,
|
||||
createdAt: true,
|
||||
avatarAssetId: true,
|
||||
assetsUsersAvatarAssetIdToassets: {
|
||||
select: {
|
||||
status: true,
|
||||
visibility: true,
|
||||
purpose: true,
|
||||
ownerUserId: true,
|
||||
objectKey: true,
|
||||
provider: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
type StoredAccount = Prisma.UserGetPayload<{ select: typeof accountSelect }>;
|
||||
type StoredProfile = Prisma.UserGetPayload<{ select: typeof profileSelect }>;
|
||||
@@ -33,7 +46,10 @@ type StoredProfile = Prisma.UserGetPayload<{ select: typeof profileSelect }>;
|
||||
export class AccountRepository implements AccountReader {
|
||||
private readonly logger = new Logger(AccountRepository.name);
|
||||
|
||||
constructor(private readonly database: PrismaService) {}
|
||||
constructor(
|
||||
private readonly database: PrismaService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
|
||||
async findById(userId: string): Promise<Account | null> {
|
||||
try {
|
||||
@@ -140,11 +156,25 @@ export class AccountRepository implements AccountReader {
|
||||
}
|
||||
|
||||
private toSummary(user: StoredProfile): UserSummary {
|
||||
// 七牛签名尚未接入,明确返回空头像,不下发对象 key、私有地址或任意外链。
|
||||
// 头像公开空间已由用户确认,仅解析本人已就绪头像,不接受任意外链。
|
||||
const avatar = user.assetsUsersAvatarAssetIdToassets;
|
||||
const avatarUrl =
|
||||
avatar?.status === 'ready' &&
|
||||
avatar.visibility === 'public' &&
|
||||
avatar.purpose === 'avatar' &&
|
||||
avatar.ownerUserId === user.id &&
|
||||
avatar.provider === 'qiniu' &&
|
||||
avatar.objectKey
|
||||
? publicObjectUrl(
|
||||
this.config.qiniuPublicDomain,
|
||||
avatar.objectKey,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: user.id,
|
||||
nickName: user.nickName,
|
||||
avatarUrl: null,
|
||||
avatarUrl,
|
||||
avatarAssetId: avatarUrl ? user.avatarAssetId : null,
|
||||
avatarUrlExpiresAt: null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,14 @@ 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 {
|
||||
WECHAT_FETCH,
|
||||
WECHAT_DIAGNOSTICS,
|
||||
WechatClient,
|
||||
type WechatDiagnostics,
|
||||
} from './wechat-client';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import { AppLogger } from '../logging/app-logger';
|
||||
import { LoginRateLimiter } from './login-rate-limiter';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginController } from './login.controller';
|
||||
@@ -21,13 +28,21 @@ import { CurrentUserController } from './current-user.controller';
|
||||
AccountRepository,
|
||||
{ provide: ACCOUNT_READER, useExisting: AccountRepository },
|
||||
{ provide: WECHAT_FETCH, useValue: fetch },
|
||||
{
|
||||
provide: WECHAT_DIAGNOSTICS,
|
||||
inject: [SERVER_CONFIG],
|
||||
useFactory: (config: ServerConfig): WechatDiagnostics => {
|
||||
const logger = new AppLogger(config.logLevel);
|
||||
return (entry) => logger.warn('wechat_exchange_failed', entry);
|
||||
},
|
||||
},
|
||||
WechatClient,
|
||||
LoginRateLimiter,
|
||||
LoginService,
|
||||
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
],
|
||||
exports: [JwtTokenService, ACCOUNT_READER],
|
||||
exports: [JwtTokenService, ACCOUNT_READER, AccountRepository],
|
||||
})
|
||||
/** 微信静默登录、本人读取与全局鉴权;身份始终来自可信微信兑换或本站凭证。 */
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -29,7 +29,11 @@ export class LoginController {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.login.login(input, request.ip ?? 'unknown'),
|
||||
data: await this.login.login(
|
||||
input,
|
||||
request.ip ?? 'unknown',
|
||||
request.requestId,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ export class LoginService {
|
||||
async login(
|
||||
input: WechatLoginRequest,
|
||||
ip: string,
|
||||
requestId?: string,
|
||||
): Promise<WechatLoginResult> {
|
||||
this.limiter.checkIp(ip);
|
||||
const identity = await this.wechat.exchangeCode(input.code);
|
||||
const identity = await this.wechat.exchangeCode(input.code, requestId);
|
||||
this.limiter.checkIdentity(identity.openid);
|
||||
const { account, user } = await this.accounts.login(
|
||||
identity,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { isIP } from 'node:net';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
@@ -11,16 +12,31 @@ export interface WechatIdentity {
|
||||
/** HTTP 边界便于测试替换;正式实现始终只请求固定微信 HTTPS 地址。 */
|
||||
export const WECHAT_FETCH = Symbol('WECHAT_FETCH');
|
||||
export type WechatFetch = typeof fetch;
|
||||
export const WECHAT_DIAGNOSTICS = Symbol('WECHAT_DIAGNOSTICS');
|
||||
export interface WechatDiagnostic {
|
||||
request_id?: string | undefined;
|
||||
failure_type: 'network' | 'http' | 'json' | 'response' | 'wechat_error';
|
||||
http_status?: number;
|
||||
wechat_errcode?: number;
|
||||
egress_ip?: string;
|
||||
}
|
||||
export type WechatDiagnostics = (entry: WechatDiagnostic) => void;
|
||||
|
||||
@Injectable()
|
||||
export class WechatClient {
|
||||
constructor(
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
@Inject(WECHAT_FETCH) private readonly http: WechatFetch,
|
||||
@Optional()
|
||||
@Inject(WECHAT_DIAGNOSTICS)
|
||||
private readonly diagnostic?: WechatDiagnostics,
|
||||
) {}
|
||||
|
||||
/** 一次 code 只兑换一次,不自动重试;超时后客户端需重新取得 code。 */
|
||||
async exchangeCode(code: string): Promise<WechatIdentity> {
|
||||
async exchangeCode(
|
||||
code: string,
|
||||
requestId?: string,
|
||||
): Promise<WechatIdentity> {
|
||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
url.search = new URLSearchParams({
|
||||
appid: this.config.wechatMiniAppId,
|
||||
@@ -28,14 +44,22 @@ export class WechatClient {
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code',
|
||||
}).toString();
|
||||
const diagnostic: WechatDiagnostic = {
|
||||
request_id: requestId,
|
||||
failure_type: 'network',
|
||||
};
|
||||
try {
|
||||
const response = await this.http(url, {
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
diagnostic.http_status = response.status;
|
||||
diagnostic.failure_type = 'http';
|
||||
if (!response.ok) throw new Error('Wechat HTTP failure');
|
||||
diagnostic.failure_type = 'json';
|
||||
const payload: unknown = await response.json();
|
||||
diagnostic.failure_type = 'response';
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
@@ -44,6 +68,16 @@ export class WechatClient {
|
||||
throw new Error('Invalid Wechat response');
|
||||
const data = payload as Record<string, unknown>;
|
||||
if (data.errcode !== undefined && data.errcode !== 0) {
|
||||
diagnostic.failure_type = 'wechat_error';
|
||||
if (Number.isSafeInteger(data.errcode))
|
||||
diagnostic.wechat_errcode = data.errcode as number;
|
||||
// 仅提取有效 IP;微信 errmsg 可能包含敏感上下文,不能原样记录。
|
||||
if (data.errcode === 40164 && typeof data.errmsg === 'string') {
|
||||
const ip = data.errmsg.match(
|
||||
/invalid ip\s+([0-9a-fA-F.:]+)/,
|
||||
)?.[1];
|
||||
if (ip && isIP(ip)) diagnostic.egress_ip = ip;
|
||||
}
|
||||
if (data.errcode === 40029 || data.errcode === 40163)
|
||||
throw new ApiException(400, 'WECHAT_CODE_INVALID');
|
||||
if (data.errcode === 40226)
|
||||
@@ -68,7 +102,9 @@ export class WechatClient {
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException) throw error;
|
||||
if (error instanceof ApiException && error.getStatus() < 500)
|
||||
throw error;
|
||||
this.diagnostic?.(diagnostic);
|
||||
// 网络异常可能包含带 AppSecret 的 URL,禁止记录或传播异常原文。
|
||||
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Put,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
@@ -104,6 +105,17 @@ export class AdminCategoryController {
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新分类(管理员,小程序 PUT 兼容入口)' })
|
||||
async updateByPut(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateCategoryDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
) {
|
||||
return this.update(id, input, actor, request);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: '删除分类(管理员,有子分类或题型引用时返回 409)',
|
||||
|
||||
@@ -11,5 +11,6 @@ import { CategoryService } from './category.service';
|
||||
imports: [DatabaseModule],
|
||||
controllers: [CategoryController, AdminCategoryController],
|
||||
providers: [CategoryService],
|
||||
exports: [CategoryService],
|
||||
})
|
||||
export class CategoryModule {}
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface ServerConfig extends RuntimeOptions {
|
||||
/** 新品依据首次发布时间判断;公共资源域名缺失时不返回七牛对象地址。 */
|
||||
worksheetNewWindowDays: number;
|
||||
qiniuPublicDomain: string | null;
|
||||
/** 与同步工具一致;前缀仅用于备份相对路径,数据库 objectKey 已是完整 key。 */
|
||||
qiniuKeyPrefix: string;
|
||||
qiniuBucket: string | null;
|
||||
qiniuAk: string;
|
||||
qiniuSk: string;
|
||||
qiniuRegion: string;
|
||||
siteRefreshHourUtc: number;
|
||||
}
|
||||
|
||||
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
|
||||
@@ -176,6 +183,36 @@ export function getServerConfig(
|
||||
);
|
||||
}
|
||||
}
|
||||
// 与 asset-sync 保持相同的 POSIX 相对前缀规则,拒绝越界与空路径段。
|
||||
const rawPrefix = env.QINIU_KEY_PREFIX ?? 'doodle-assets/';
|
||||
const prefix = rawPrefix.replace(/\/+$/, '');
|
||||
if (
|
||||
rawPrefix.startsWith('/') ||
|
||||
rawPrefix.includes('\\') ||
|
||||
[...rawPrefix].some(
|
||||
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
||||
) ||
|
||||
(prefix &&
|
||||
prefix
|
||||
.split('/')
|
||||
.some((part) => !part || part === '.' || part === '..'))
|
||||
)
|
||||
throw new Error(
|
||||
'QINIU_KEY_PREFIX must be a relative object key prefix',
|
||||
);
|
||||
const qiniuBucket = (env.QINIU_BUCKET ?? '').trim() || null;
|
||||
if (qiniuBucket && !/^[a-z0-9][a-z0-9-]{1,62}$/.test(qiniuBucket))
|
||||
throw new Error('QINIU_BUCKET must be a valid bucket name');
|
||||
// 上传密钥可整体留空用于只读服务,但不能只配置一项或夹带空白。
|
||||
const qiniuAk = (env.QINIU_AK ?? '').trim();
|
||||
const qiniuSk = (env.QINIU_SK ?? '').trim();
|
||||
const qiniuRegion = (env.QINIU_REGION ?? '').trim();
|
||||
if (Boolean(qiniuAk) !== Boolean(qiniuSk) || /\s/.test(qiniuAk + qiniuSk))
|
||||
throw new Error(
|
||||
'QINIU_AK and QINIU_SK must be configured together without whitespace',
|
||||
);
|
||||
if (qiniuRegion && !/^[a-z0-9][a-z0-9-]{0,30}$/.test(qiniuRegion))
|
||||
throw new Error('QINIU_REGION must be a region ID');
|
||||
return {
|
||||
...getRuntimeOptions(env),
|
||||
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
|
||||
@@ -204,6 +241,12 @@ export function getServerConfig(
|
||||
365,
|
||||
),
|
||||
qiniuPublicDomain,
|
||||
qiniuKeyPrefix: prefix ? `${prefix}/` : '',
|
||||
qiniuBucket,
|
||||
qiniuAk,
|
||||
qiniuSk,
|
||||
qiniuRegion,
|
||||
siteRefreshHourUtc: integer(env, 'SITE_REFRESH_HOUR_UTC', 22, 0, 23),
|
||||
jwtExpiresInSeconds: integer(
|
||||
env,
|
||||
'JWT_EXPIRES_IN_SECONDS',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
@@ -59,6 +60,17 @@ export class AdminWorksheetController {
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@ApiOperation({ summary: '更新发布状态(管理员,小程序 PUT 兼容入口)' })
|
||||
async updateByPut(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateContentStatusDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
) {
|
||||
return this.update(id, input, actor, request);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({
|
||||
summary: '题型发布、下架或转草稿(管理员);发布要求分类已上线',
|
||||
@@ -115,6 +127,17 @@ export class AdminResourceController {
|
||||
return { code: 0, message: 'ok', data: await this.resources.get(id) };
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@ApiOperation({ summary: '更新发布状态(管理员,小程序 PUT 兼容入口)' })
|
||||
async updateByPut(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateContentStatusDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
) {
|
||||
return this.update(id, input, actor, request);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({
|
||||
summary: '资源发布、下架或转草稿(管理员);发布要求题型及分类已上线',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publicObjectUrl } from './public-resource-url';
|
||||
|
||||
/** 仅解析已就绪的公开预览图;管理预览同样不绕过资产访问规则。 */
|
||||
export interface PreviewAssetRow {
|
||||
assetStatus: string;
|
||||
@@ -39,15 +41,7 @@ export function previewUrl(
|
||||
}
|
||||
}
|
||||
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 publicObjectUrl(qiniuPublicDomain, row.assetKey);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ServerConfig } from '../config/server-config';
|
||||
|
||||
/** 数据库 objectKey 已含上传前缀;逐段编码且拒绝越界,不再次拼接前缀。 */
|
||||
export function publicObjectUrl(
|
||||
domain: string | null,
|
||||
objectKey: string,
|
||||
): string | null {
|
||||
const parts = objectKey.split('/');
|
||||
if (
|
||||
!domain ||
|
||||
objectKey.includes('\\') ||
|
||||
[...objectKey].some(
|
||||
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
||||
) ||
|
||||
parts.some((part) => !part || part === '.' || part === '..')
|
||||
)
|
||||
return null;
|
||||
return `${domain}/${parts.map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
/** 字体等固定公开资源传备份相对路径,由配置统一补上传前缀;不解析旧 cloud 引用。 */
|
||||
export function publicResourceUrl(
|
||||
config: Pick<ServerConfig, 'qiniuPublicDomain' | 'qiniuKeyPrefix'>,
|
||||
relativePath: string,
|
||||
): string | null {
|
||||
// 先独立验证相对路径,防止空路径或绝对路径被前缀掩盖。
|
||||
if (!publicObjectUrl(config.qiniuPublicDomain, relativePath)) return null;
|
||||
return publicObjectUrl(
|
||||
config.qiniuPublicDomain,
|
||||
`${config.qiniuKeyPrefix}${relativePath}`,
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const levels: Record<LogLevel, number> = {
|
||||
};
|
||||
// 按结构化字段名递归脱敏;调用方仍不得将 token/query 等拼入普通消息字符串。
|
||||
const sensitiveKeys =
|
||||
/authorization|cookie|password|secret|token|api[_-]?key|session[_-]?key|openid|unionid|^code$|body|query/i;
|
||||
/authorization|cookie|password|secret|token|api[_-]?key|access[_-]?key|(?:^|_)(?:ak|sk)$|qiniu(?:ak|sk)|session[_-]?key|openid|unionid|^code$|body|query/i;
|
||||
|
||||
/** 将日志上下文转为可安全序列化的值,处理密钥、bigint 和循环引用。 */
|
||||
function sanitize(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { requireCurrentAccount } from '../auth/account-policy';
|
||||
import { visibleWorksheet } from '../content/content-policy';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
/** 用户行锁保证资料、注销、收藏和下载写入使用同一时刻的有效身份。 */
|
||||
export async function lockMember(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: AuthenticatedUser,
|
||||
) {
|
||||
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`;
|
||||
return requireCurrentAccount(
|
||||
rows[0] ?? null,
|
||||
actor.userId,
|
||||
actor.authVersion,
|
||||
);
|
||||
}
|
||||
/** 交互写入仍要求题型和分类链已上线;锁顺序为用户、分类、题型。 */
|
||||
export async function lockVisibleWorksheet(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
) {
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE MODE`;
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{ id: string }>
|
||||
>`SELECT w.id FROM public.worksheets w WHERE w.id=${id} AND ${visibleWorksheet} FOR UPDATE`;
|
||||
if (!rows.length) throw new NotFoundException();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
|
||||
import { MemberService } from './member.service';
|
||||
import {
|
||||
ProfileDto,
|
||||
MemberPageDto,
|
||||
FavoriteCheckDto,
|
||||
DownloadDto,
|
||||
} from './member.dto';
|
||||
|
||||
const ok = <T>(data: T) => ({ code: 0 as const, message: 'ok', data });
|
||||
@Controller('me')
|
||||
@ApiTags('me')
|
||||
/** 所有本人接口默认鉴权,不接受 userId/openid 等身份字段。 */
|
||||
export class MemberController {
|
||||
constructor(private readonly member: MemberService) {}
|
||||
@Put('profile')
|
||||
@ApiOperation({ summary: '更新本人昵称及头像资源 ID' })
|
||||
async profile(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Body() input: ProfileDto,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return ok(await this.member.profile(actor, input, req.requestId!));
|
||||
}
|
||||
@Delete('profile')
|
||||
@ApiOperation({ summary: '清除本人昵称和头像' })
|
||||
async clear(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return ok(await this.member.clearProfile(actor, req.requestId!));
|
||||
}
|
||||
@Delete()
|
||||
@ApiOperation({ summary: '注销本人账户并删除本人收藏、下载明细' })
|
||||
async remove(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return ok(await this.member.deleteAccount(actor, req.requestId!));
|
||||
}
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: '本人收藏与下载计数' })
|
||||
async stats(@CurrentUser() actor: AuthenticatedUser) {
|
||||
return ok(await this.member.stats(actor));
|
||||
}
|
||||
@Get('favorites')
|
||||
@ApiOperation({ summary: '分页查询本人收藏;下架题型不返回详情' })
|
||||
async favorites(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Query() query: MemberPageDto,
|
||||
) {
|
||||
return ok(await this.member.favorites(actor, query));
|
||||
}
|
||||
@Post('favorites/check')
|
||||
@ApiOperation({ summary: '批量检查本人题型收藏状态' })
|
||||
async states(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Body() input: FavoriteCheckDto,
|
||||
) {
|
||||
return ok(await this.member.favoriteStates(actor, input.worksheetIds));
|
||||
}
|
||||
@Put('favorites/:id')
|
||||
@ApiOperation({ summary: '幂等收藏题型,并事务增加真实点赞数' })
|
||||
async add(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return ok(await this.member.favorite(actor, id, true));
|
||||
}
|
||||
@Delete('favorites/:id')
|
||||
@ApiOperation({ summary: '幂等取消本人收藏' })
|
||||
async unstar(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return ok(await this.member.favorite(actor, id, false));
|
||||
}
|
||||
@Get('downloads')
|
||||
@ApiOperation({ summary: '分页查询本人成功导出记录' })
|
||||
async downloads(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Query() query: MemberPageDto,
|
||||
) {
|
||||
return ok(await this.member.downloads(actor, query));
|
||||
}
|
||||
@Post('downloads')
|
||||
@ApiOperation({
|
||||
summary: '按用户与 exportId 幂等记录成功导出,并增加下载量',
|
||||
})
|
||||
async log(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Body() input: DownloadDto,
|
||||
) {
|
||||
return ok(await this.member.download(actor, input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ArrayMaxSize,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/** 所有本人接口忽略客户端身份来源,DTO 仅接收业务字段。 */
|
||||
export class ProfileDto {
|
||||
@ApiPropertyOptional({ type: String, maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
nickName?: string;
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: '本人 ready 头像资源 ID;null 清除头像',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
avatarAssetId?: string | null;
|
||||
}
|
||||
export class MemberPageDto {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
maximum: 10000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
page?: string;
|
||||
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 30 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pageSize?: string;
|
||||
}
|
||||
export class FavoriteCheckDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(128, { each: true })
|
||||
@ApiProperty({ type: [String], maxItems: 100 })
|
||||
worksheetIds!: string[];
|
||||
}
|
||||
export class DownloadDto {
|
||||
@ApiProperty({ example: 'number-find' })
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
worksheetId!: string;
|
||||
@ApiProperty({ type: String, format: 'uuid' })
|
||||
@IsUUID()
|
||||
exportId!: string;
|
||||
}
|
||||
export class UploadDto {
|
||||
@ApiProperty({ type: String, format: 'uuid' })
|
||||
@IsUUID()
|
||||
uploadId!: string;
|
||||
}
|
||||
export class AgeRecommendDto {
|
||||
@ApiProperty({ enum: ['3-4', '4-5', '5-6', '6-7', '7-8'], example: '5-6' })
|
||||
@IsIn(['3-4', '4-5', '5-6', '6-7', '7-8'])
|
||||
ageBand!: string;
|
||||
}
|
||||
export function memberPage(input: MemberPageDto) {
|
||||
const number = (
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
max: number,
|
||||
) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(value) ||
|
||||
!Number.isSafeInteger(Number(value)) ||
|
||||
Number(value) > max
|
||||
)
|
||||
throw new BadRequestException('分页参数不合法');
|
||||
return Number(value);
|
||||
};
|
||||
const page = number(input.page, 1, 10000);
|
||||
const pageSize = number(input.pageSize, 20, 30);
|
||||
return { page, pageSize, skip: (page - 1) * pageSize };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WorksheetModule } from '../worksheets/worksheet.module';
|
||||
import { MemberController } from './member.controller';
|
||||
import { MemberService } from './member.service';
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, WorksheetModule],
|
||||
controllers: [MemberController],
|
||||
providers: [MemberService],
|
||||
})
|
||||
export class MemberModule {}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||
import { AccountRepository } from '../auth/account.repository';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { lockMember, lockVisibleWorksheet } from './member-policy';
|
||||
import {
|
||||
memberPage,
|
||||
type MemberPageDto,
|
||||
type ProfileDto,
|
||||
type DownloadDto,
|
||||
} from './member.dto';
|
||||
import { runContentQuery } from '../content/content-policy';
|
||||
|
||||
@Injectable()
|
||||
/** 本人业务从 JWT 取身份,幂等写入与统计在同一事务内完成。 */
|
||||
export class MemberService {
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
private readonly worksheets: WorksheetService,
|
||||
private readonly accounts: AccountRepository,
|
||||
) {}
|
||||
|
||||
async profile(
|
||||
actor: AuthenticatedUser,
|
||||
input: ProfileDto,
|
||||
requestId: string,
|
||||
) {
|
||||
await runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockMember(tx, actor);
|
||||
const data: {
|
||||
nickName?: string | null;
|
||||
avatarAssetId?: string | null;
|
||||
updatedAt: Date;
|
||||
} = { updatedAt: new Date() };
|
||||
if (input.nickName !== undefined) {
|
||||
if (typeof input.nickName !== 'string')
|
||||
throw new BadRequestException();
|
||||
data.nickName = input.nickName.trim() || null;
|
||||
}
|
||||
if (input.avatarAssetId !== undefined) {
|
||||
if (input.avatarAssetId) {
|
||||
const asset = await tx.asset.findUnique({
|
||||
where: { id: input.avatarAssetId },
|
||||
});
|
||||
if (
|
||||
!asset ||
|
||||
asset.ownerUserId !== actor.userId ||
|
||||
asset.purpose !== 'avatar' ||
|
||||
asset.status !== 'ready' ||
|
||||
asset.visibility !== 'public' ||
|
||||
asset.provider !== 'qiniu' ||
|
||||
!asset.objectKey ||
|
||||
!asset.mimeType?.startsWith('image/')
|
||||
)
|
||||
throw new BadRequestException('头像资产不可用');
|
||||
}
|
||||
data.avatarAssetId = input.avatarAssetId || null;
|
||||
}
|
||||
const before = await tx.user.findUniqueOrThrow({
|
||||
where: { id: actor.userId },
|
||||
select: { avatarAssetId: true },
|
||||
});
|
||||
await tx.user.update({ where: { id: actor.userId }, data });
|
||||
if (
|
||||
data.avatarAssetId !== undefined &&
|
||||
before.avatarAssetId &&
|
||||
before.avatarAssetId !== data.avatarAssetId
|
||||
)
|
||||
await tx.asset.updateMany({
|
||||
where: {
|
||||
id: before.avatarAssetId,
|
||||
ownerUserId: actor.userId,
|
||||
purpose: 'avatar',
|
||||
},
|
||||
data: { status: 'deleting', updatedAt: new Date() },
|
||||
});
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorUserId: actor.userId,
|
||||
actorType: 'user',
|
||||
action: 'user.profile',
|
||||
resourceType: 'users',
|
||||
resourceId: actor.userId,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
return this.accounts.getProfile(actor);
|
||||
}
|
||||
|
||||
async clearProfile(actor: AuthenticatedUser, requestId: string) {
|
||||
return this.profile(
|
||||
actor,
|
||||
{ nickName: '', avatarAssetId: null },
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAccount(actor: AuthenticatedUser, requestId: string) {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockMember(tx, actor);
|
||||
// 历史计数留作累计指标;删除本人明细,注销后旧 JWT 因账户不存在而失效。
|
||||
const favorites = await tx.favorite.findMany({
|
||||
where: { userId: actor.userId, worksheetResourceId: null },
|
||||
orderBy: { worksheetId: 'asc' },
|
||||
});
|
||||
for (const favorite of favorites)
|
||||
await tx.worksheet.updateMany({
|
||||
where: { id: favorite.worksheetId, likes: { gt: 0 } },
|
||||
data: { likes: { decrement: 1 } },
|
||||
});
|
||||
await tx.favorite.deleteMany({
|
||||
where: { userId: actor.userId },
|
||||
});
|
||||
await tx.downloadLog.deleteMany({
|
||||
where: { userId: actor.userId },
|
||||
});
|
||||
await tx.user.update({
|
||||
where: { id: actor.userId },
|
||||
data: { avatarAssetId: null },
|
||||
});
|
||||
await tx.asset.updateMany({
|
||||
where: { ownerUserId: actor.userId },
|
||||
data: {
|
||||
ownerUserId: null,
|
||||
status: 'deleting',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.adminAuditLog.updateMany({
|
||||
where: { actorUserId: actor.userId },
|
||||
data: { actorUserId: null },
|
||||
});
|
||||
// 审计保留业务 ID,不保留 OpenID、昵称或用户提交内容。
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorType: 'user',
|
||||
action: 'user.delete',
|
||||
resourceType: 'users',
|
||||
resourceId: actor.userId,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: actor.userId } });
|
||||
return { deleted: true };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async stats(actor: AuthenticatedUser) {
|
||||
return runContentQuery(async () => {
|
||||
const [favoriteCount, downloadCount, user] =
|
||||
await this.db.client.$transaction([
|
||||
this.db.client.favorite.count({
|
||||
where: { userId: actor.userId },
|
||||
}),
|
||||
this.db.client.downloadLog.count({
|
||||
where: { userId: actor.userId },
|
||||
}),
|
||||
this.db.client.user.findUnique({
|
||||
where: { id: actor.userId },
|
||||
select: { totalDownloads: true },
|
||||
}),
|
||||
]);
|
||||
if (!user) throw new NotFoundException();
|
||||
return {
|
||||
favoriteCount,
|
||||
downloadCount,
|
||||
totalDownloads: user.totalDownloads.toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async favorites(actor: AuthenticatedUser, input: MemberPageDto) {
|
||||
return this.records('favorite', actor, input);
|
||||
}
|
||||
async downloads(actor: AuthenticatedUser, input: MemberPageDto) {
|
||||
return this.records('download', actor, input);
|
||||
}
|
||||
private async records(
|
||||
kind: 'favorite' | 'download',
|
||||
actor: AuthenticatedUser,
|
||||
input: MemberPageDto,
|
||||
) {
|
||||
const { page, pageSize, skip } = memberPage(input);
|
||||
return runContentQuery(async () => {
|
||||
const where = { userId: actor.userId };
|
||||
const options = {
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: [
|
||||
{ createdAt: 'desc' as const },
|
||||
{ id: 'desc' as const },
|
||||
],
|
||||
};
|
||||
const [rows, total] =
|
||||
kind === 'favorite'
|
||||
? await this.db.client.$transaction([
|
||||
this.db.client.favorite.findMany(options),
|
||||
this.db.client.favorite.count({ where }),
|
||||
])
|
||||
: await this.db.client.$transaction([
|
||||
this.db.client.downloadLog.findMany(options),
|
||||
this.db.client.downloadLog.count({ where }),
|
||||
]);
|
||||
const items = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
// 已下架/删除题型仅返回本人记录,附属题型留空,不能泄漏草稿内容。
|
||||
let worksheet = null;
|
||||
try {
|
||||
worksheet = await this.worksheets.get(row.worksheetId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof NotFoundException)) throw error;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
worksheetId: row.worksheetId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
...('exportId' in row
|
||||
? { exportId: row.exportId }
|
||||
: {}),
|
||||
worksheet,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasNext: page * pageSize < total,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async favoriteStates(actor: AuthenticatedUser, ids: string[]) {
|
||||
return runContentQuery(async () => {
|
||||
const rows = await this.db.client.favorite.findMany({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
worksheetId: { in: ids },
|
||||
worksheetResourceId: null,
|
||||
},
|
||||
select: { worksheetId: true },
|
||||
});
|
||||
return Object.fromEntries(
|
||||
rows.map((row) => [row.worksheetId, true]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async favorite(actor: AuthenticatedUser, id: string, enabled: boolean) {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockMember(tx, actor);
|
||||
if (enabled) await lockVisibleWorksheet(tx, id);
|
||||
const where = {
|
||||
userId: actor.userId,
|
||||
worksheetId: id,
|
||||
worksheetResourceId: null,
|
||||
};
|
||||
const existing = await tx.favorite.findFirst({ where });
|
||||
if (enabled && !existing) {
|
||||
await tx.favorite.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: actor.userId,
|
||||
worksheetId: id,
|
||||
},
|
||||
});
|
||||
await tx.worksheet.update({
|
||||
where: { id },
|
||||
data: { likes: { increment: 1 } },
|
||||
});
|
||||
} else if (!enabled && existing) {
|
||||
await tx.favorite.delete({ where: { id: existing.id } });
|
||||
await tx.worksheet.updateMany({
|
||||
where: { id, likes: { gt: 0 } },
|
||||
data: { likes: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
return { worksheetId: id, favorited: enabled };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async download(actor: AuthenticatedUser, input: DownloadDto) {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockMember(tx, actor);
|
||||
const existing = await tx.downloadLog.findUnique({
|
||||
where: {
|
||||
userId_exportId: {
|
||||
userId: actor.userId,
|
||||
exportId: input.exportId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.worksheetId !== input.worksheetId)
|
||||
throw new ConflictException('导出标识已用于其他题型');
|
||||
return { id: existing.id, duplicate: true };
|
||||
}
|
||||
await lockVisibleWorksheet(tx, input.worksheetId);
|
||||
const row = await tx.downloadLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: actor.userId,
|
||||
worksheetId: input.worksheetId,
|
||||
exportId: input.exportId,
|
||||
source: 'mini',
|
||||
},
|
||||
});
|
||||
await tx.worksheet.update({
|
||||
where: { id: input.worksheetId },
|
||||
data: { downloads: { increment: 1 } },
|
||||
});
|
||||
await tx.user.update({
|
||||
where: { id: actor.userId },
|
||||
data: { totalDownloads: { increment: 1 } },
|
||||
});
|
||||
return { id: row.id, duplicate: false };
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AdminWorksheetQueryDto } from '../content/content.dto';
|
||||
import { Body, Controller, Post, Req, Get, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser, Roles } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
|
||||
import { PublishWorksheetDto, WorksheetBatchDto } from './admin-worksheet.dto';
|
||||
import { AdminWorksheetService } from './admin-worksheet.service';
|
||||
@Controller('admin/worksheets')
|
||||
@Roles('admin')
|
||||
@ApiTags('admin/worksheets')
|
||||
export class AdminWorksheetWriteController {
|
||||
constructor(private readonly worksheets: AdminWorksheetService) {}
|
||||
@Post('publish')
|
||||
@ApiOperation({
|
||||
summary: '管理员创建或更新题型元数据,预览使用 ready 资产 ID',
|
||||
})
|
||||
async publish(
|
||||
@Body() input: PublishWorksheetDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.publish(input, actor, req.requestId!),
|
||||
};
|
||||
}
|
||||
@Post('batch')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'管理员原子批量修改年龄、标签、年级、真实计数或展示种子,支持预演',
|
||||
})
|
||||
async batch(
|
||||
@Body() input: WorksheetBatchDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.batch(input, actor, req.requestId!),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/catalog')
|
||||
@Roles('admin')
|
||||
@ApiTags('admin/catalog')
|
||||
export class AdminCatalogController {
|
||||
constructor(private readonly worksheets: AdminWorksheetService) {}
|
||||
@Get('worksheets')
|
||||
@ApiOperation({ summary: '管理员完整题型目录,真实计数与展示种子分开返回' })
|
||||
async catalog(@Query() query: AdminWorksheetQueryDto) {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.catalog(query),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Allow } from 'class-validator';
|
||||
/** 管理发布只接收可审阅的元数据,渲染内部参数不允许由此接口任意写入。 */
|
||||
export class PublishWorksheetDto {
|
||||
@ApiProperty({ type: String, example: 'number-find' })
|
||||
@Allow()
|
||||
declare id: unknown;
|
||||
@ApiProperty({ type: String, example: '数字找一找' })
|
||||
@Allow()
|
||||
declare title: unknown;
|
||||
@ApiProperty({ type: String, example: '' })
|
||||
@Allow()
|
||||
declare subtitle: unknown;
|
||||
@ApiProperty({ type: String, example: 'math' })
|
||||
@Allow()
|
||||
declare categoryId: unknown;
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
@Allow()
|
||||
declare subcategory?: unknown;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
example: '/mathPages/mathDraw/mathDraw?id=number-find',
|
||||
})
|
||||
@Allow()
|
||||
declare path: unknown;
|
||||
@ApiProperty({ type: Number, example: 3 })
|
||||
@Allow()
|
||||
declare ageMin: unknown;
|
||||
@ApiProperty({ type: Number, example: 8 })
|
||||
@Allow()
|
||||
declare ageMax: unknown;
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
description: '旧单年级字段;公开查询使用 grades 关系',
|
||||
})
|
||||
@Allow()
|
||||
declare grade?: unknown;
|
||||
@ApiPropertyOptional({ type: [Number], example: [1, 3, 5] })
|
||||
@Allow()
|
||||
declare grades?: unknown;
|
||||
@ApiProperty({ type: Number, minimum: 1, maximum: 4 })
|
||||
@Allow()
|
||||
declare difficulty: unknown;
|
||||
@ApiProperty({ type: String, description: '已 ready 的公开预览资源 ID' })
|
||||
@Allow()
|
||||
declare previewAssetId: unknown;
|
||||
@ApiProperty({ type: [String] })
|
||||
@Allow()
|
||||
declare tags: unknown;
|
||||
@ApiPropertyOptional({ type: Boolean })
|
||||
@Allow()
|
||||
declare isNew?: unknown;
|
||||
@ApiPropertyOptional({ type: Boolean })
|
||||
@Allow()
|
||||
declare isHot?: unknown;
|
||||
@ApiPropertyOptional({ type: Number })
|
||||
@Allow()
|
||||
declare sortOrder?: unknown;
|
||||
@ApiPropertyOptional({
|
||||
enum: ['draft', 'active', 'hidden'],
|
||||
default: 'draft',
|
||||
})
|
||||
@Allow()
|
||||
declare status?: unknown;
|
||||
}
|
||||
export class WorksheetBatchDto {
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
maxItems: 500,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
ageMin: { type: 'integer' },
|
||||
ageMax: { type: 'integer' },
|
||||
grades: { type: 'array', items: { type: 'integer' } },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
likesSeed: { type: 'string' },
|
||||
downloadsSeed: { type: 'string' },
|
||||
likes: { type: 'string' },
|
||||
downloads: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
})
|
||||
@Allow()
|
||||
declare patches: unknown;
|
||||
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||
@Allow()
|
||||
declare dryRun?: unknown;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||
import type { AdminWorksheetQueryDto } from '../content/content.dto';
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import {
|
||||
lockContentAdmin,
|
||||
contentStatus,
|
||||
invalidateContentSnapshots,
|
||||
runContentQuery,
|
||||
visibleCategoryIds,
|
||||
} from '../content/content-policy';
|
||||
import type {
|
||||
PublishWorksheetDto,
|
||||
WorksheetBatchDto,
|
||||
} from './admin-worksheet.dto';
|
||||
|
||||
function text(value: unknown, max: number, empty = false): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length > max ||
|
||||
(!empty && !value.trim()) ||
|
||||
/[\r\n\0]/.test(value)
|
||||
)
|
||||
throw new BadRequestException('文本字段不合法');
|
||||
return value.trim();
|
||||
}
|
||||
function integer(value: unknown, min: number, max: number) {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < min ||
|
||||
value > max
|
||||
)
|
||||
throw new BadRequestException('数值字段不合法');
|
||||
return value;
|
||||
}
|
||||
function tags(value: unknown) {
|
||||
if (!Array.isArray(value) || value.length > 30)
|
||||
throw new BadRequestException('tags 不合法');
|
||||
return [...new Set(value.map((tag) => text(tag, 50)))];
|
||||
}
|
||||
function grades(value: unknown) {
|
||||
if (!Array.isArray(value) || value.length > 20)
|
||||
throw new BadRequestException('grades 不合法');
|
||||
return [...new Set(value.map((grade) => integer(grade, -4, 12)))];
|
||||
}
|
||||
function count(value: unknown) {
|
||||
const raw =
|
||||
typeof value === 'number' && Number.isSafeInteger(value)
|
||||
? String(value)
|
||||
: value;
|
||||
if (
|
||||
typeof raw !== 'string' ||
|
||||
!/^\d{1,19}$/.test(raw) ||
|
||||
BigInt(raw) > 9223372036854775807n
|
||||
)
|
||||
throw new BadRequestException('统计数值不合法');
|
||||
return BigInt(raw);
|
||||
}
|
||||
/** 仅记录白名单元数据,BigInt 转十进制字符串;不把整个数据库实体写入审计。 */
|
||||
function auditSnapshot(value: unknown): Prisma.InputJsonValue {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
typeof item === 'bigint' ? item.toString() : item,
|
||||
),
|
||||
) as Prisma.InputJsonValue;
|
||||
}
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new BadRequestException();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
/** 批量元数据只修改显式字段;不允许附带 user/owner/资产等越权字段。 */
|
||||
export function batchPatch(value: unknown) {
|
||||
const row = object(value);
|
||||
const allowed = new Set([
|
||||
'id',
|
||||
'ageMin',
|
||||
'ageMax',
|
||||
'grade',
|
||||
'grades',
|
||||
'tags',
|
||||
'likesSeed',
|
||||
'downloadsSeed',
|
||||
'likes',
|
||||
'downloads',
|
||||
]);
|
||||
if (Object.keys(row).some((key) => !allowed.has(key)))
|
||||
throw new BadRequestException('未知修改字段');
|
||||
const id = text(row.id, 128);
|
||||
const data: Prisma.WorksheetUpdateInput = { updatedAt: new Date() };
|
||||
if ((row.ageMin === undefined) !== (row.ageMax === undefined))
|
||||
throw new BadRequestException('年龄上下限须成对提供');
|
||||
if (row.ageMin !== undefined) {
|
||||
data.ageMin = integer(row.ageMin, 0, 18);
|
||||
data.ageMax = integer(row.ageMax, data.ageMin, 18);
|
||||
}
|
||||
if (row.grade !== undefined) data.grade = integer(row.grade, -4, 12);
|
||||
if (row.tags !== undefined) data.tags = tags(row.tags);
|
||||
for (const field of [
|
||||
'likesSeed',
|
||||
'downloadsSeed',
|
||||
'likes',
|
||||
'downloads',
|
||||
] as const)
|
||||
if (row[field] !== undefined) data[field] = count(row[field]);
|
||||
const selectedGrades =
|
||||
row.grades === undefined ? undefined : grades(row.grades);
|
||||
if (Object.keys(row).length < 2)
|
||||
throw new BadRequestException('未指定修改字段');
|
||||
return { id, data, grades: selectedGrades };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 管理元数据写入采用账户→分类→资产→题型锁序,批次原子提交并保留审计。 */
|
||||
export class AdminWorksheetService {
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
private readonly worksheets: WorksheetService,
|
||||
) {}
|
||||
/** 管理目录显式返回真实计数及展示种子,避免把合并数回写真实统计。 */
|
||||
async catalog(query: AdminWorksheetQueryDto) {
|
||||
const page = await this.worksheets.listForAdmin(query);
|
||||
const counts = await this.db.client.worksheet.findMany({
|
||||
where: { id: { in: page.items.map((row) => row.id) } },
|
||||
select: {
|
||||
id: true,
|
||||
likes: true,
|
||||
downloads: true,
|
||||
likesSeed: true,
|
||||
downloadsSeed: true,
|
||||
},
|
||||
});
|
||||
const byId = new Map(counts.map((row) => [row.id, row]));
|
||||
return {
|
||||
...page,
|
||||
items: page.items.map((row) => {
|
||||
const raw = byId.get(row.id)!;
|
||||
return {
|
||||
...row,
|
||||
likesDisplay: row.likes,
|
||||
downloadsDisplay: row.downloads,
|
||||
likes: raw.likes.toString(),
|
||||
downloads: raw.downloads.toString(),
|
||||
likesSeed: raw.likesSeed.toString(),
|
||||
downloadsSeed: raw.downloadsSeed.toString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
async batch(
|
||||
input: WorksheetBatchDto,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
) {
|
||||
if (
|
||||
!Array.isArray(input.patches) ||
|
||||
!input.patches.length ||
|
||||
input.patches.length > 500 ||
|
||||
(input.dryRun !== undefined && typeof input.dryRun !== 'boolean')
|
||||
)
|
||||
throw new BadRequestException('批量参数不合法');
|
||||
const patches = input.patches
|
||||
.map(batchPatch)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
if (new Set(patches.map((p) => p.id)).size !== patches.length)
|
||||
throw new BadRequestException('重复题型');
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(
|
||||
async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
// 批量编辑与统计写入串行化,避免跨接口的多行锁顺序产生死锁。
|
||||
await tx.$executeRaw`LOCK TABLE public.worksheets IN SHARE ROW EXCLUSIVE MODE`;
|
||||
for (const patch of patches) {
|
||||
const before = await tx.worksheet.findUnique({
|
||||
where: { id: patch.id },
|
||||
});
|
||||
if (!before) throw new NotFoundException('题型不存在');
|
||||
if (!input.dryRun) {
|
||||
const beforeGrades =
|
||||
patch.grades === undefined
|
||||
? undefined
|
||||
: (
|
||||
await tx.worksheetGrade.findMany({
|
||||
where: { worksheetId: patch.id },
|
||||
orderBy: { grade: 'asc' },
|
||||
})
|
||||
).map((row) => row.grade);
|
||||
await tx.worksheet.update({
|
||||
where: { id: patch.id },
|
||||
data: patch.data,
|
||||
});
|
||||
if (patch.grades !== undefined) {
|
||||
await tx.worksheetGrade.deleteMany({
|
||||
where: { worksheetId: patch.id },
|
||||
});
|
||||
await tx.worksheetGrade.createMany({
|
||||
data: patch.grades.map((grade) => ({
|
||||
worksheetId: patch.id,
|
||||
grade,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await this.audit(
|
||||
tx,
|
||||
actor,
|
||||
requestId,
|
||||
patch.id,
|
||||
'worksheet.batch',
|
||||
auditSnapshot({
|
||||
...patch.data,
|
||||
...(patch.grades === undefined
|
||||
? {}
|
||||
: { grades: patch.grades }),
|
||||
}),
|
||||
auditSnapshot({
|
||||
...Object.fromEntries(
|
||||
Object.keys(patch.data).map((key) => [
|
||||
key,
|
||||
(
|
||||
before as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)[key],
|
||||
]),
|
||||
),
|
||||
...(beforeGrades === undefined
|
||||
? {}
|
||||
: { grades: beforeGrades }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!input.dryRun) await invalidateContentSnapshots(tx);
|
||||
return {
|
||||
dryRun: input.dryRun === true,
|
||||
total: patches.length,
|
||||
failed: 0,
|
||||
results: patches.map((p) => ({ id: p.id, ok: true })),
|
||||
};
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
async publish(
|
||||
input: PublishWorksheetDto,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
) {
|
||||
const id = text(input.id, 128);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id))
|
||||
throw new BadRequestException('题型 ID 不合法');
|
||||
const categoryId = text(input.categoryId, 128);
|
||||
const ageMin = integer(input.ageMin, 0, 18);
|
||||
const ageMax = integer(input.ageMax, ageMin, 18);
|
||||
const path = text(input.path, 1024);
|
||||
if (
|
||||
!/^\/(?:pages|mathPages|focusPages|pinyinPages|chinesePages|englishPages|papersPages)\/[A-Za-z0-9_/-]+(?:\?[^\s]*)?$/.test(
|
||||
path,
|
||||
) ||
|
||||
path.includes('..')
|
||||
)
|
||||
throw new BadRequestException('小程序路由不合法');
|
||||
const status = contentStatus(input.status ?? 'draft');
|
||||
const data = {
|
||||
title: text(input.title, 100),
|
||||
subtitle: text(input.subtitle, 500, true),
|
||||
categoryId,
|
||||
subcategory:
|
||||
input.subcategory == null
|
||||
? null
|
||||
: text(input.subcategory, 100, true),
|
||||
path,
|
||||
ageMin,
|
||||
ageMax,
|
||||
grade: input.grade === undefined ? 0 : integer(input.grade, -4, 12),
|
||||
difficulty: integer(input.difficulty, 1, 4),
|
||||
previewAssetId: text(input.previewAssetId, 128),
|
||||
tags: tags(input.tags),
|
||||
status,
|
||||
sortOrder:
|
||||
input.sortOrder === undefined
|
||||
? 0
|
||||
: integer(input.sortOrder, -1000000, 1000000),
|
||||
updatedAt: new Date(),
|
||||
contentUpdatedAt: new Date(),
|
||||
};
|
||||
for (const field of ['isNew', 'isHot'] as const)
|
||||
if (input[field] !== undefined && typeof input[field] !== 'boolean')
|
||||
throw new BadRequestException('布尔字段不合法');
|
||||
const selectedGrades =
|
||||
input.grades === undefined ? undefined : grades(input.grades);
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
await tx.$executeRaw`LOCK TABLE public.assets IN SHARE MODE`;
|
||||
if (
|
||||
!(await tx.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
}))
|
||||
)
|
||||
throw new BadRequestException('分类不存在');
|
||||
if (status === 'active') {
|
||||
const visible = await tx.$queryRaw<
|
||||
Array<{ id: string }>
|
||||
>`SELECT id FROM public.categories WHERE id=${categoryId} AND id IN (${visibleCategoryIds})`;
|
||||
if (!visible.length)
|
||||
throw new BadRequestException('分类尚未上线');
|
||||
}
|
||||
const asset = await tx.asset.findUnique({
|
||||
where: { id: data.previewAssetId },
|
||||
});
|
||||
if (
|
||||
!asset ||
|
||||
asset.purpose !== 'preview' ||
|
||||
asset.status !== 'ready' ||
|
||||
asset.visibility !== 'public' ||
|
||||
!asset.mimeType?.startsWith('image/')
|
||||
)
|
||||
throw new BadRequestException('预览资产未就绪');
|
||||
const existing = await tx.worksheet.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
const flags = {
|
||||
...(input.isNew === undefined
|
||||
? {}
|
||||
: { isNew: input.isNew as boolean }),
|
||||
...(input.isHot === undefined
|
||||
? {}
|
||||
: { isHot: input.isHot as boolean }),
|
||||
};
|
||||
const history =
|
||||
existing &&
|
||||
existing.status === 'draft' &&
|
||||
!existing.firstPublishedAt
|
||||
? await tx.adminAuditLog.findMany({
|
||||
where: {
|
||||
resourceType: 'worksheets',
|
||||
resourceId: id,
|
||||
},
|
||||
select: { beforeData: true, afterData: true },
|
||||
})
|
||||
: [];
|
||||
const wasPublished = history.some((entry) =>
|
||||
[entry.beforeData, entry.afterData].some((snapshot) => {
|
||||
if (
|
||||
!snapshot ||
|
||||
typeof snapshot !== 'object' ||
|
||||
Array.isArray(snapshot)
|
||||
)
|
||||
return false;
|
||||
return ['active', 'hidden'].includes(
|
||||
String(snapshot.status),
|
||||
);
|
||||
}),
|
||||
);
|
||||
// 元数据重发不能把旧 active/hidden 的未知首发时间伪造成“今天新品”。
|
||||
const firstPublishedAt =
|
||||
existing?.firstPublishedAt ??
|
||||
(status === 'active' &&
|
||||
(!existing ||
|
||||
(existing.status === 'draft' && !wasPublished))
|
||||
? new Date()
|
||||
: null);
|
||||
await tx.worksheet.upsert({
|
||||
where: { id },
|
||||
create: { id, ...data, ...flags, firstPublishedAt },
|
||||
update: { ...data, ...flags, firstPublishedAt },
|
||||
});
|
||||
if (selectedGrades !== undefined) {
|
||||
await tx.worksheetGrade.deleteMany({
|
||||
where: { worksheetId: id },
|
||||
});
|
||||
await tx.worksheetGrade.createMany({
|
||||
data: selectedGrades.map((grade) => ({
|
||||
worksheetId: id,
|
||||
grade,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await invalidateContentSnapshots(tx);
|
||||
await this.audit(
|
||||
tx,
|
||||
actor,
|
||||
requestId,
|
||||
id,
|
||||
'worksheet.publish',
|
||||
auditSnapshot({
|
||||
...data,
|
||||
...flags,
|
||||
firstPublishedAt,
|
||||
...(selectedGrades === undefined
|
||||
? {}
|
||||
: { grades: selectedGrades }),
|
||||
}),
|
||||
existing
|
||||
? auditSnapshot(
|
||||
Object.fromEntries(
|
||||
[
|
||||
...Object.keys(data),
|
||||
...Object.keys(flags),
|
||||
'firstPublishedAt',
|
||||
].map((key) => [
|
||||
key,
|
||||
(
|
||||
existing as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)[key],
|
||||
]),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
return { id, status };
|
||||
}),
|
||||
);
|
||||
}
|
||||
private async audit(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
id: string,
|
||||
action: string,
|
||||
data: Prisma.InputJsonValue,
|
||||
beforeData?: Prisma.InputJsonValue,
|
||||
) {
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorUserId: actor.userId,
|
||||
actorType: 'admin',
|
||||
action,
|
||||
resourceType: 'worksheets',
|
||||
resourceId: id,
|
||||
requestId,
|
||||
afterData: data,
|
||||
...(beforeData === undefined ? {} : { beforeData }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Post, Put, Query, Req } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public, Roles, CurrentUser } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
|
||||
import { SiteService } from './site.service';
|
||||
import { AgeRecommendDto } from '../member/member.dto';
|
||||
import { RebuildSiteDto, SiteTimerDto } from './site.dto';
|
||||
const ok = <T>(data: T) => ({ code: 0 as const, message: 'ok', data });
|
||||
@Controller()
|
||||
@Public()
|
||||
@ApiTags('site')
|
||||
export class SiteController {
|
||||
constructor(private readonly site: SiteService) {}
|
||||
@Get('page-config')
|
||||
@ApiOperation({
|
||||
summary: '首页配置动态兜底,按当前发布状态过滤',
|
||||
security: [],
|
||||
})
|
||||
async current() {
|
||||
return ok(await this.site.current());
|
||||
}
|
||||
@Get('recommendations/age')
|
||||
@ApiOperation({
|
||||
summary: '年龄段完整覆盖的推荐:下载 Top3、点赞 Top3 去重后补齐六项',
|
||||
security: [],
|
||||
})
|
||||
async recommendations(@Query() query: AgeRecommendDto) {
|
||||
return ok(await this.site.recommendations(query.ageBand));
|
||||
}
|
||||
}
|
||||
@Controller('admin/site')
|
||||
@Roles('admin')
|
||||
@ApiTags('admin/site')
|
||||
export class AdminSiteController {
|
||||
constructor(private readonly site: SiteService) {}
|
||||
@Get('config')
|
||||
@ApiOperation({ summary: '查询当前首页配置(管理员)' })
|
||||
async current() {
|
||||
return ok(await this.site.current());
|
||||
}
|
||||
@Get('timer')
|
||||
@ApiOperation({ summary: '查询首页每日刷新开关及 UTC 小时(管理员)' })
|
||||
async timer() {
|
||||
return ok(await this.site.timerState());
|
||||
}
|
||||
@Put('timer')
|
||||
@ApiOperation({ summary: '启停首页快照每日刷新(管理员)' })
|
||||
async setTimer(
|
||||
@Body() input: SiteTimerDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return ok(
|
||||
await this.site.setTimer(input.enabled, actor, req.requestId!),
|
||||
);
|
||||
}
|
||||
@Post('rebuild')
|
||||
@ApiOperation({
|
||||
summary: '重建首页/分类动态展示配置,保留旧预拉取地址(管理员)',
|
||||
})
|
||||
async rebuild(
|
||||
@Body() input: RebuildSiteDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() req: HttpRequest,
|
||||
) {
|
||||
return ok(await this.site.rebuild(input, actor, req.requestId!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Allow, IsBoolean, IsIn } from 'class-validator';
|
||||
export class RebuildSiteDto {
|
||||
@ApiProperty({ enum: ['home', 'category', 'all'] })
|
||||
@IsIn(['home', 'category', 'all'])
|
||||
page!: 'home' | 'category' | 'all';
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@Allow()
|
||||
declare featuredIds?: unknown;
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@Allow()
|
||||
declare hotIds?: unknown;
|
||||
@ApiPropertyOptional({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
subtitle: { type: 'string' },
|
||||
morePath: { type: 'string' },
|
||||
worksheetIds: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Allow()
|
||||
declare sections?: unknown;
|
||||
}
|
||||
export class SiteTimerDto {
|
||||
@ApiProperty({ type: Boolean })
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { WorksheetModule } from '../worksheets/worksheet.module';
|
||||
import { SiteController, AdminSiteController } from './site.controller';
|
||||
import { SiteService } from './site.service';
|
||||
import {
|
||||
AdminWorksheetWriteController,
|
||||
AdminCatalogController,
|
||||
} from './admin-worksheet.controller';
|
||||
import { AdminWorksheetService } from './admin-worksheet.service';
|
||||
@Module({
|
||||
imports: [DatabaseModule, WorksheetModule],
|
||||
controllers: [
|
||||
SiteController,
|
||||
AdminSiteController,
|
||||
AdminWorksheetWriteController,
|
||||
AdminCatalogController,
|
||||
],
|
||||
providers: [SiteService, AdminWorksheetService],
|
||||
})
|
||||
export class SiteModule {}
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
type OnModuleInit,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PublicWorksheet } from '@doodle/contracts';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import {
|
||||
lockContentAdmin,
|
||||
runContentQuery,
|
||||
visibleCategoryIds,
|
||||
} from '../content/content-policy';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import type { RebuildSiteDto } from './site.dto';
|
||||
import { worksheetAgeBands } from '../worksheets/worksheet-query.dto';
|
||||
|
||||
interface SectionSeed {
|
||||
id: string;
|
||||
subtitle: string;
|
||||
morePath: string;
|
||||
worksheetIds: string[];
|
||||
}
|
||||
interface HomeSeed {
|
||||
featuredIds: string[];
|
||||
hotIds: string[];
|
||||
sections: SectionSeed[];
|
||||
}
|
||||
const difficulty = ['入门', '基础', '进阶', '挑战'];
|
||||
const ageLabels = ['启蒙认知', '基础练习', '能力提升', '幼小衔接', '知识拓展'];
|
||||
const idArray = (value: unknown): string[] => {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > 100 ||
|
||||
value.some((id) => typeof id !== 'string' || !id || id.length > 128)
|
||||
)
|
||||
throw new BadRequestException('题型 ID 列表不合法');
|
||||
return [...new Set(value as string[])];
|
||||
};
|
||||
/** 展示排序保留十进制大计数精度,不能经 Number 比较。 */
|
||||
export function rankWorksheets(
|
||||
rows: PublicWorksheet[],
|
||||
field: 'likes' | 'downloads',
|
||||
) {
|
||||
return [...rows].sort((a, b) =>
|
||||
BigInt(a[field]) === BigInt(b[field])
|
||||
? b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id)
|
||||
: BigInt(a[field]) > BigInt(b[field])
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 动态首页兜底与分龄推荐不读取旧云库;本次不改微信预拉取地址或上传其旧配置文件。 */
|
||||
export class SiteService implements OnModuleInit, OnModuleDestroy {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private running = false;
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
private readonly worksheets: WorksheetService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
private async dataset(tx: Prisma.TransactionClient) {
|
||||
const categories = await tx.$queryRaw<
|
||||
Array<{ id: string; name: string; icon: string }>
|
||||
>`SELECT id,name,CASE WHEN icon_asset_id IS NULL AND icon NOT LIKE 'cloud:%' THEN icon ELSE '' END AS icon FROM public.categories WHERE id IN (${visibleCategoryIds}) ORDER BY sort_order,id`;
|
||||
const rows = await this.worksheets.snapshot(tx);
|
||||
return { categories, rows };
|
||||
}
|
||||
private seed(input: RebuildSiteDto, previous: unknown): HomeSeed | null {
|
||||
if (
|
||||
input.featuredIds === undefined &&
|
||||
input.hotIds === undefined &&
|
||||
input.sections === undefined
|
||||
)
|
||||
return previous as HomeSeed | null;
|
||||
if (!Array.isArray(input.sections) || input.sections.length > 100)
|
||||
throw new BadRequestException('首页分区不合法');
|
||||
const sections = input.sections.map((value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new BadRequestException();
|
||||
const row = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(row).some(
|
||||
(key) =>
|
||||
![
|
||||
'id',
|
||||
'subtitle',
|
||||
'morePath',
|
||||
'worksheetIds',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof row.id !== 'string' ||
|
||||
!row.id ||
|
||||
row.id.length > 128
|
||||
)
|
||||
throw new BadRequestException();
|
||||
const subtitle = row.subtitle ?? '';
|
||||
const morePath = row.morePath ?? '';
|
||||
if (
|
||||
typeof subtitle !== 'string' ||
|
||||
subtitle.length > 500 ||
|
||||
typeof morePath !== 'string' ||
|
||||
morePath.length > 1024 ||
|
||||
(morePath &&
|
||||
(!morePath.startsWith('/') || morePath.startsWith('//')))
|
||||
)
|
||||
throw new BadRequestException();
|
||||
return {
|
||||
id: row.id,
|
||||
subtitle,
|
||||
morePath,
|
||||
worksheetIds: idArray(row.worksheetIds),
|
||||
};
|
||||
});
|
||||
if (new Set(sections.map((s) => s.id)).size !== sections.length)
|
||||
throw new BadRequestException('首页分区重复');
|
||||
return {
|
||||
featuredIds: idArray(input.featuredIds),
|
||||
hotIds: idArray(input.hotIds),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
private build(
|
||||
data: Awaited<ReturnType<SiteService['dataset']>>,
|
||||
seed: HomeSeed | null,
|
||||
) {
|
||||
const { rows, categories } = data;
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const homeItem = (row: PublicWorksheet) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
category: row.category,
|
||||
ageBand: `${row.ageMin}-${row.ageMax}岁`,
|
||||
difficulty: difficulty[row.difficulty - 1] || '基础',
|
||||
icon: '',
|
||||
previewImg: row.previewImg || '',
|
||||
badge: row.isNew ? 'NEW' : row.isHot ? 'HOT' : '',
|
||||
path: row.path,
|
||||
available: !!row.path,
|
||||
});
|
||||
const picked = (ids: string[]) =>
|
||||
ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((row): row is PublicWorksheet => !!row)
|
||||
.map(homeItem);
|
||||
const sections =
|
||||
seed?.sections ??
|
||||
categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
subtitle: '',
|
||||
morePath: `/pages/category/category?id=${encodeURIComponent(cat.id)}`,
|
||||
worksheetIds: rows
|
||||
.filter((row) => row.category === cat.id)
|
||||
.slice(0, 6)
|
||||
.map((row) => row.id),
|
||||
}));
|
||||
const home = {
|
||||
searchPlaceholder: '搜索你喜欢的练习册...',
|
||||
categoryTabs: [
|
||||
{ id: 'all', name: '全部' },
|
||||
...categories.map((cat) => ({ id: cat.id, name: cat.name })),
|
||||
],
|
||||
ageBands: Object.keys(worksheetAgeBands).map((key, index) => ({
|
||||
key,
|
||||
label: `${key} 岁`,
|
||||
desc: ageLabels[index],
|
||||
path: '/pages/age/age',
|
||||
})),
|
||||
featured: picked(
|
||||
seed?.featuredIds ??
|
||||
rankWorksheets(rows, 'downloads')
|
||||
.slice(0, 5)
|
||||
.map((row) => row.id),
|
||||
),
|
||||
hot: picked(
|
||||
seed?.hotIds ??
|
||||
rankWorksheets(rows, 'likes')
|
||||
.slice(0, 5)
|
||||
.map((row) => row.id),
|
||||
),
|
||||
sections: sections
|
||||
.filter((section) =>
|
||||
categories.some((cat) => cat.id === section.id),
|
||||
)
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: categories.find((cat) => cat.id === section.id)!
|
||||
.name,
|
||||
subtitle: section.subtitle,
|
||||
morePath: section.morePath,
|
||||
items: picked(section.worksheetIds),
|
||||
})),
|
||||
};
|
||||
const category = {
|
||||
searchPlaceholder: '搜索练习纸...',
|
||||
categories: categories.map((cat) => ({
|
||||
...cat,
|
||||
items: rows
|
||||
.filter((row) => row.category === cat.id)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
previewImg: row.previewImg || '',
|
||||
ageBand: `${row.ageMin}-${row.ageMax}岁`,
|
||||
difficultyLabel:
|
||||
difficulty[row.difficulty - 1] || '基础',
|
||||
})),
|
||||
})),
|
||||
};
|
||||
return { home, category };
|
||||
}
|
||||
/** 每次重新按发布状态过滤,旧首页快照不能恢复已下架内容。 */
|
||||
async current() {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(
|
||||
async (tx) => {
|
||||
const saved = await tx.setting.findUnique({
|
||||
where: { id: 'homeLayout' },
|
||||
});
|
||||
const snapshot = this.build(
|
||||
await this.dataset(tx),
|
||||
(saved?.value ?? null) as unknown as HomeSeed | null,
|
||||
);
|
||||
const version = await tx.pageConfig.findUnique({
|
||||
where: { id: 'current' },
|
||||
select: { version: true },
|
||||
});
|
||||
return {
|
||||
...snapshot,
|
||||
version: (version?.version ?? 0n).toString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
async recommendations(ageBand: string) {
|
||||
const band =
|
||||
worksheetAgeBands[ageBand as keyof typeof worksheetAgeBands];
|
||||
if (!band) throw new BadRequestException('年龄段不合法');
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(
|
||||
async (tx) => {
|
||||
const { rows } = await this.dataset(tx);
|
||||
const matched = rows.filter(
|
||||
(row) => row.ageMin <= band[0] && row.ageMax >= band[1],
|
||||
);
|
||||
const used = new Set<string>();
|
||||
const take = (input: PublicWorksheet[], limit: number) => {
|
||||
const selected: PublicWorksheet[] = [];
|
||||
for (const row of input)
|
||||
if (!used.has(row.id) && selected.length < limit) {
|
||||
used.add(row.id);
|
||||
selected.push(row);
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
const selected = [
|
||||
...take(rankWorksheets(matched, 'downloads'), 3),
|
||||
...take(rankWorksheets(matched, 'likes'), 3),
|
||||
];
|
||||
selected.push(
|
||||
...take(
|
||||
rankWorksheets(rows, 'downloads'),
|
||||
6 - selected.length,
|
||||
),
|
||||
);
|
||||
return { ageBand, items: selected };
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
async timerState() {
|
||||
return runContentQuery(async () => {
|
||||
const row = await this.db.client.setting.findUnique({
|
||||
where: { id: 'homeAutoRefresh' },
|
||||
});
|
||||
return {
|
||||
enabled:
|
||||
(row?.value as { enabled?: boolean } | undefined)
|
||||
?.enabled !== false,
|
||||
hourUtc: this.config.siteRefreshHourUtc,
|
||||
};
|
||||
});
|
||||
}
|
||||
async setTimer(
|
||||
enabled: boolean,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
) {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await tx.setting.upsert({
|
||||
where: { id: 'homeAutoRefresh' },
|
||||
create: { id: 'homeAutoRefresh', value: { enabled } },
|
||||
update: {
|
||||
value: { enabled },
|
||||
version: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorUserId: actor.userId,
|
||||
actorType: 'admin',
|
||||
action: 'site.timer',
|
||||
resourceType: 'settings',
|
||||
resourceId: 'homeAutoRefresh',
|
||||
requestId,
|
||||
afterData: { enabled },
|
||||
},
|
||||
});
|
||||
return { enabled, hourUtc: this.config.siteRefreshHourUtc };
|
||||
}),
|
||||
);
|
||||
}
|
||||
async rebuild(
|
||||
input: RebuildSiteDto,
|
||||
actor?: AuthenticatedUser,
|
||||
requestId = `site-job-${randomUUID()}`,
|
||||
) {
|
||||
return runContentQuery(() =>
|
||||
this.db.client.$transaction(
|
||||
async (tx) => {
|
||||
if (actor) await lockContentAdmin(tx, actor);
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
await tx.$executeRaw`LOCK TABLE public.assets, public.worksheets IN SHARE MODE`;
|
||||
await tx.$executeRaw`LOCK TABLE public.settings IN SHARE ROW EXCLUSIVE MODE`;
|
||||
const saved = await tx.setting.findUnique({
|
||||
where: { id: 'homeLayout' },
|
||||
});
|
||||
const dataset = await this.dataset(tx);
|
||||
let seed = this.seed(input, saved?.value);
|
||||
if (!actor) {
|
||||
// 每日任务按当前下载/点赞重新生成推荐,保留手工分区内容。
|
||||
seed = {
|
||||
featuredIds: rankWorksheets(
|
||||
dataset.rows,
|
||||
'downloads',
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((row) => row.id),
|
||||
hotIds: rankWorksheets(dataset.rows, 'likes')
|
||||
.slice(0, 5)
|
||||
.map((row) => row.id),
|
||||
sections:
|
||||
seed?.sections ??
|
||||
dataset.categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
subtitle: '',
|
||||
morePath: `/pages/category/category?id=${encodeURIComponent(cat.id)}`,
|
||||
worksheetIds: dataset.rows
|
||||
.filter(
|
||||
(row) => row.category === cat.id,
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((row) => row.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (seed)
|
||||
await tx.setting.upsert({
|
||||
where: { id: 'homeLayout' },
|
||||
create: {
|
||||
id: 'homeLayout',
|
||||
value: seed as unknown as Prisma.InputJsonObject,
|
||||
},
|
||||
update: {
|
||||
value: seed as unknown as Prisma.InputJsonObject,
|
||||
version: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const snapshot = this.build(dataset, seed);
|
||||
const stored = await tx.pageConfig.upsert({
|
||||
where: { id: 'current' },
|
||||
create: { id: 'current', ...snapshot, version: 1n },
|
||||
update: {
|
||||
...snapshot,
|
||||
version: { increment: 1 },
|
||||
publishedVersion: null,
|
||||
publishedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
...(actor ? { actorUserId: actor.userId } : {}),
|
||||
actorType: actor ? 'admin' : 'system',
|
||||
action: 'site.rebuild',
|
||||
resourceType: 'page_configs',
|
||||
resourceId: 'current',
|
||||
requestId,
|
||||
afterData: {
|
||||
page: input.page,
|
||||
version: stored.version.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
version: stored.version.toString(),
|
||||
updatedAt: stored.updatedAt.toISOString(),
|
||||
stats: Object.fromEntries(
|
||||
snapshot.category.categories.map((cat) => [
|
||||
cat.id,
|
||||
cat.items.length,
|
||||
]),
|
||||
),
|
||||
};
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
onModuleInit() {
|
||||
if (this.config.nodeEnv !== 'test') {
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, 60000);
|
||||
this.timer.unref();
|
||||
}
|
||||
}
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
/** 多实例通过 jobName/runKey 与租约抢占;失败最多重试三次,实际任务不触碰旧预拉取文件。 */
|
||||
async tick(now = new Date()) {
|
||||
if (
|
||||
this.running ||
|
||||
now.getUTCHours() !== this.config.siteRefreshHourUtc
|
||||
)
|
||||
return;
|
||||
this.running = true;
|
||||
let jobId: string | undefined;
|
||||
const token = randomUUID();
|
||||
try {
|
||||
if (!(await this.timerState()).enabled) return;
|
||||
const runKey = now.toISOString().slice(0, 10);
|
||||
const job = await this.db.client.jobRun.upsert({
|
||||
where: {
|
||||
jobName_runKey: { jobName: 'home-auto-refresh', runKey },
|
||||
},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
jobName: 'home-auto-refresh',
|
||||
runKey,
|
||||
triggerType: 'scheduled',
|
||||
scheduledAt: now,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
const claim = await this.db.client.jobRun.updateMany({
|
||||
where: {
|
||||
id: job.id,
|
||||
attempt: { lt: 3 },
|
||||
OR: [
|
||||
{ status: { in: ['pending', 'failed'] } },
|
||||
{ status: 'running', lockedUntil: { lt: now } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: 'running',
|
||||
lockToken: token,
|
||||
lockedUntil: new Date(now.getTime() + 120000),
|
||||
startedAt: now,
|
||||
attempt: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (!claim.count) return;
|
||||
jobId = job.id;
|
||||
const result = await this.rebuild({ page: 'all' });
|
||||
await this.db.client.jobRun.updateMany({
|
||||
where: { id: jobId, lockToken: token },
|
||||
data: {
|
||||
status: 'succeeded',
|
||||
finishedAt: new Date(),
|
||||
lockedUntil: null,
|
||||
result,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (jobId)
|
||||
await this.db.client.jobRun
|
||||
.updateMany({
|
||||
where: { id: jobId, lockToken: token },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: 'SITE_REFRESH_FAILED',
|
||||
lockedUntil: null,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@ import { WorksheetService } from './worksheet.service';
|
||||
AdminResourceController,
|
||||
],
|
||||
providers: [WorksheetService, PublicationService, ResourcePreviewService],
|
||||
exports: [WorksheetService],
|
||||
})
|
||||
export class WorksheetModule {}
|
||||
|
||||
@@ -68,6 +68,17 @@ export class WorksheetService {
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
|
||||
/** 首页和推荐在调用者事务中获取同一份公开快照,复用完整可见性与资产解析规则。 */
|
||||
async snapshot(tx: Prisma.TransactionClient): Promise<PublicWorksheet[]> {
|
||||
const rows = await tx.$queryRaw<
|
||||
WorksheetRow[]
|
||||
>`SELECT ${this.columns(this.isNewExpression(new Date()))}
|
||||
FROM public.worksheets w JOIN public.assets a ON a.id=w.preview_asset_id
|
||||
WHERE ${visibleWorksheet} ORDER BY w.sort_order, w.updated_at DESC, w.id LIMIT 10001`;
|
||||
if (rows.length > 10000) throw new Error('Site snapshot exceeds limit');
|
||||
return rows.map((row) => this.toPublic(row));
|
||||
}
|
||||
|
||||
/** 公开查询不提供状态开关,固定要求题型及全部上级分类上线。 */
|
||||
async list(input: WorksheetQueryDto): Promise<WorksheetPage> {
|
||||
const query = parseWorksheetQuery(input);
|
||||
|
||||
Reference in New Issue
Block a user