feat: 本地开发环境配置 + Python 3.9 兼容修复
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 42s
Deploy API Server / current-integration (push) Failing after 3m6s
Deploy API Server / backward-compat (push) Successful in 17s
Deploy API Server / deploy (push) Has been skipped

- 添加 docker-compose.local.yml(MySQL/Redis/Qdrant 本地基础设施)
- 添加 mysql.conf.d 本地 MySQL 低资源配置
- RAG Worker Python 3.9 兼容(from __future__ import annotations)
- 同步最新业务代码变更
This commit is contained in:
wangdl 2026-06-27 12:42:44 +08:00
parent cfa6c6bc9c
commit 05cf369bee
37 changed files with 1519 additions and 193 deletions

51
docker-compose.local.yml Normal file
View File

@ -0,0 +1,51 @@
# 知习 本地开发环境 Docker Compose
# 只包含基础设施MySQL/Redis/QdrantAPI/Worker/RAG Worker 在宿主机运行
services:
mysql:
image: mysql:8.0
container_name: zhixi-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: Zhixi@2026!Root_8C
MYSQL_DATABASE: zhixi_prod
ports:
- '3306:3306'
volumes:
- mysql_local:/var/lib/mysql
- ./mysql.conf.d:/etc/mysql/conf.d:ro
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: zhixi-redis
restart: unless-stopped
ports:
- '6379:6379'
volumes:
- redis_local:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
qdrant:
image: qdrant/qdrant:latest
container_name: zhixi-qdrant
restart: unless-stopped
ports:
- '6333:6333'
- '6334:6334'
volumes:
- qdrant_local:/qdrant/storage
# Qdrant image 不带 wget/curl跳过 healthcheck
volumes:
mysql_local:
redis_local:
qdrant_local:

14
mysql.conf.d/zhixi.cnf Normal file
View File

@ -0,0 +1,14 @@
[mysqld]
# 本地开发配置 — 调低资源占用
innodb_buffer_pool_size = 512M
innodb_buffer_pool_instances = 1
innodb_redo_log_capacity = 256M
innodb_flush_log_at_trx_commit = 1
innodb_file_per_table = 1
max_connections = 50
wait_timeout = 300
interactive_timeout = 300
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
slow_query_log = 0
skip-log-bin

View File

@ -14,7 +14,8 @@
"start:prod": "node dist/src/main", "start:prod": "node dist/src/main",
"start:worker": "node dist/src/worker.main", "start:worker": "node dist/src/worker.main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "npm run test:unit",
"test:unit": "jest --config ./test/jest-unit.json",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@ -88,15 +89,24 @@
"json", "json",
"ts" "ts"
], ],
"rootDir": "src", "rootDir": ".",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": [
"\\.integration-spec\\.ts$",
"\\.integration\\.spec\\.ts$",
"\\.worker-int-spec\\.ts$",
"\\.e2e-spec\\.ts$"
],
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "src/**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "./coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

View File

@ -38,6 +38,7 @@ model User {
knowledgeItemRelations KnowledgeItemRelation[] knowledgeItemRelations KnowledgeItemRelation[]
tags Tag[] tags Tag[]
uploadedFiles UploadedFile[] uploadedFiles UploadedFile[]
uploadSessions UploadSession[]
documentImports DocumentImport[] documentImports DocumentImport[]
learningSessions LearningSession[] learningSessions LearningSession[]
learningRecords LearningRecord[] learningRecords LearningRecord[]
@ -330,7 +331,7 @@ model UploadedFile {
filename String @db.VarChar(255) filename String @db.VarChar(255)
mimeType String? @db.VarChar(100) mimeType String? @db.VarChar(100)
storagePath String @db.VarChar(500) storagePath String @db.VarChar(500)
objectKey String? @db.VarChar(500) objectKey String? @unique @db.VarChar(500)
bucket String? @db.VarChar(100) bucket String? @db.VarChar(100)
sizeBytes BigInt @default(0) sizeBytes BigInt @default(0)
checksum String? @db.VarChar(255) checksum String? @db.VarChar(255)
@ -340,12 +341,35 @@ model UploadedFile {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
sources KnowledgeSource[] sources KnowledgeSource[]
uploadSession UploadSession?
@@index([userId]) @@index([userId])
@@index([objectKey]) @@index([objectKey])
@@index([sha256]) @@index([sha256])
} }
model UploadSession {
id String @id @default(cuid())
userId String
uploadedFileId String? @unique
objectKey String @unique @db.VarChar(500)
declaredSize BigInt
reservedBytes BigInt
quotaOperationId String @unique @db.VarChar(255)
status String @default("created") @db.VarChar(32)
expiresAt DateTime
uploadedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
uploadedFile UploadedFile? @relation(fields: [uploadedFileId], references: [id])
@@index([userId, status])
@@index([expiresAt])
}
model DocumentImport { model DocumentImport {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import httpx import httpx
from config import API_BASE_URL, RAG_WORKER_SECRET, WORKER_ID from config import API_BASE_URL, RAG_WORKER_SECRET, WORKER_ID

View File

@ -1,3 +1,5 @@
from __future__ import annotations
"""文本切片:递归字符分割 + 中文分句保护""" """文本切片:递归字符分割 + 中文分句保护"""
import re import re

View File

@ -10,6 +10,7 @@ export interface CreateUploadUrlInput {
filename: string; filename: string;
mimeType: string; mimeType: string;
sizeBytes: number; sizeBytes: number;
objectKey?: string;
} }
export interface UploadUrlResult { export interface UploadUrlResult {
@ -41,11 +42,13 @@ export class StorageService {
return `${basePath}/${filename}`; return `${basePath}/${filename}`;
} }
generateObjectKey(userId: string, originalFilename: string): string { generateObjectKey(userId: string, originalFilename: string, uploadId?: string): string {
const date = new Date(); const date = new Date();
const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`; const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`;
const safeName = sanitizeFilename(originalFilename); const safeName = sanitizeFilename(originalFilename);
return `${userId}/${yearMonth}/${safeName}`; return uploadId
? `${userId}/${yearMonth}/${uploadId}-${safeName}`
: `${userId}/${yearMonth}/${safeName}`;
} }
async createUploadUrl( async createUploadUrl(
@ -55,9 +58,15 @@ export class StorageService {
): Promise<UploadUrlResult> { ): Promise<UploadUrlResult> {
validateFileUpload(input.mimeType, input.sizeBytes); validateFileUpload(input.mimeType, input.sizeBytes);
const objectKey = this.generateObjectKey(userId, input.filename); const objectKey = input.objectKey ?? this.generateObjectKey(userId, input.filename);
const result = await this.cos.generateUploadUrl(objectKey, expiresIn); return this.createUploadUrlForObject(objectKey, expiresIn);
}
async createUploadUrlForObject(
objectKey: string,
expiresIn = 3600,
): Promise<UploadUrlResult> {
const result = await this.cos.generateUploadUrl(objectKey, expiresIn);
return { return {
uploadUrl: result.uploadUrl, uploadUrl: result.uploadUrl,
objectKey: result.objectKey, objectKey: result.objectKey,

View File

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import type { Prisma } from '@prisma/client';
import { ActiveRecallProjector } from './active-recall-projector'; import { ActiveRecallProjector } from './active-recall-projector';
import { ProjectionContext } from './result-projector.interface'; import { ProjectionContext } from './result-projector.interface';
@ -43,6 +44,7 @@ function makeContext(overrides?: Partial<ProjectionContext['job']>): ProjectionC
} }
function createMockTx() { function createMockTx() {
type ArtifactRef = { artifactType: string; artifactId: string; ordinal: number };
const store: Record<string, any[]> = { const store: Record<string, any[]> = {
aiAnalysisResult: [], aiAnalysisResult: [],
focusItem: [], focusItem: [],
@ -81,7 +83,7 @@ function createMockTx() {
}), }),
}, },
aiJobArtifact: { aiJobArtifact: {
findMany: jest.fn(async () => []), findMany: jest.fn<Promise<ArtifactRef[]>, [unknown]>(async () => []),
create: jest.fn(async (args: any) => { create: jest.fn(async (args: any) => {
const record = { ...args.data }; const record = { ...args.data };
store.aiJobArtifact.push(record); store.aiJobArtifact.push(record);
@ -91,6 +93,8 @@ function createMockTx() {
}; };
} }
type PrismaTx = Prisma.TransactionClient;
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Tests // Tests
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@ -115,7 +119,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
// AiAnalysisResult + 1 FocusItem (from focusItems) + 1 ReviewCard = 3 artifacts // AiAnalysisResult + 1 FocusItem (from focusItems) + 1 ReviewCard = 3 artifacts
expect(artifacts.length).toBe(3); expect(artifacts.length).toBe(3);
@ -136,7 +140,7 @@ describe('ActiveRecallProjector', () => {
tx.aiAnalysisResult.upsert.mockRejectedValue(new Error('DB error')); tx.aiAnalysisResult.upsert.mockRejectedValue(new Error('DB error'));
const ctx = makeContext(); const ctx = makeContext();
await expect(projector.project(tx as any, ctx)).rejects.toThrow('DB error'); await expect(projector.project(tx as unknown as PrismaTx, ctx)).rejects.toThrow('DB error');
// 后续写入不应发生 // 后续写入不应发生
expect(tx.store.focusItem.length).toBe(0); expect(tx.store.focusItem.length).toBe(0);
@ -148,7 +152,7 @@ describe('ActiveRecallProjector', () => {
tx.focusItem.create.mockRejectedValue(new Error('FocusItem insert failed')); tx.focusItem.create.mockRejectedValue(new Error('FocusItem insert failed'));
const ctx = makeContext(); const ctx = makeContext();
await expect(projector.project(tx as any, ctx)).rejects.toThrow('FocusItem insert failed'); await expect(projector.project(tx as unknown as PrismaTx, ctx)).rejects.toThrow('FocusItem insert failed');
// AiAnalysisResult 已写入但 FocusItem 失败 → 依赖 tx 回滚 // AiAnalysisResult 已写入但 FocusItem 失败 → 依赖 tx 回滚
// (实际 Prisma tx 会回滚,这里验证异常传播) // (实际 Prisma tx 会回滚,这里验证异常传播)
@ -159,7 +163,7 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
ctx.validatedOutput.focusItems = []; ctx.validatedOutput.focusItems = [];
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
expect(tx.store.focusItem.length).toBe(0); expect(tx.store.focusItem.length).toBe(0);
expect(artifacts.filter((a) => a.artifactType === 'FocusItem').length).toBe(0); expect(artifacts.filter((a) => a.artifactType === 'FocusItem').length).toBe(0);
@ -170,7 +174,7 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
ctx.validatedOutput.reviewSuggestion = { shouldReview: false }; ctx.validatedOutput.reviewSuggestion = { shouldReview: false };
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
expect(tx.store.reviewCard.length).toBe(0); expect(tx.store.reviewCard.length).toBe(0);
expect(artifacts.filter((a) => a.artifactType === 'ReviewCard').length).toBe(0); expect(artifacts.filter((a) => a.artifactType === 'ReviewCard').length).toBe(0);
@ -183,16 +187,16 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
// 第一次执行 // 第一次执行
const a1 = await projector.project(tx as any, ctx); const a1 = await projector.project(tx as unknown as PrismaTx, ctx);
expect(a1.length).toBe(3); expect(a1.length).toBe(3);
// 第二次执行(同一 jobId // 第二次执行(同一 jobId
// 模拟已有 ArtifactSyntheticResultProjector 模式) // 模拟已有 ArtifactSyntheticResultProjector 模式)
tx.aiJobArtifact.findMany.mockResolvedValue( tx.aiJobArtifact.findMany.mockImplementation(async () =>
tx.store.aiJobArtifact.map((a: any) => ({ ...a })), tx.store.aiJobArtifact.map((a) => ({ ...a })),
); );
const a2 = await projector.project(tx as any, ctx); const a2 = await projector.project(tx as unknown as PrismaTx, ctx);
expect(a2.length).toBe(3); // 返回已有引用,不重复创建 expect(a2.length).toBe(3); // 返回已有引用,不重复创建
// 未调用 create/upsert直接返回已有 artifacts // 未调用 create/upsert直接返回已有 artifacts
}); });
@ -203,10 +207,10 @@ describe('ActiveRecallProjector', () => {
const existingArtifacts = [ const existingArtifacts = [
{ artifactType: 'AiAnalysisResult', artifactId: 'ar_existing', ordinal: 0 }, { artifactType: 'AiAnalysisResult', artifactId: 'ar_existing', ordinal: 0 },
]; ];
tx.aiJobArtifact.findMany.mockResolvedValue(existingArtifacts); tx.aiJobArtifact.findMany.mockImplementation(async () => existingArtifacts);
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
expect(artifacts.length).toBe(1); expect(artifacts.length).toBe(1);
expect(artifacts[0].artifactId).toBe('ar_existing'); expect(artifacts[0].artifactId).toBe('ar_existing');
@ -218,13 +222,13 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
// 第一次 // 第一次
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
const firstResultId = tx.store.aiAnalysisResult[0].id; const firstResultId = tx.store.aiAnalysisResult[0].id;
// 第二次(重置 mock 返回空 artifact → 触发重新投影) // 第二次(重置 mock 返回空 artifact → 触发重新投影)
tx.aiJobArtifact.findMany.mockResolvedValue([]); tx.aiJobArtifact.findMany.mockImplementation(async () => []);
// 但 upsert 找到已有记录 → update // 但 upsert 找到已有记录 → update
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
// 仍只有 1 条 AiAnalysisResultupsert非重复插入 // 仍只有 1 条 AiAnalysisResultupsert非重复插入
expect(tx.store.aiAnalysisResult.length).toBe(1); expect(tx.store.aiAnalysisResult.length).toBe(1);
@ -236,11 +240,11 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
// 第一次写入 artifact // 第一次写入 artifact
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
expect(tx.aiJobArtifact.create).toHaveBeenCalled(); expect(tx.aiJobArtifact.create).toHaveBeenCalled();
// 第二次 — P2002 冲突 → 幂等跳过 // 第二次 — P2002 冲突 → 幂等跳过
tx.aiJobArtifact.findMany.mockResolvedValue([]); tx.aiJobArtifact.findMany.mockImplementation(async () => []);
tx.aiJobArtifact.create.mockRejectedValue({ code: 'P2002' }); tx.aiJobArtifact.create.mockRejectedValue({ code: 'P2002' });
// 不应抛出P2002 被 catch // 不应抛出P2002 被 catch
@ -254,7 +258,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
for (const a of artifacts) { for (const a of artifacts) {
expect(a.artifactType).toBeTruthy(); expect(a.artifactType).toBeTruthy();
@ -267,7 +271,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
const arArtifact = artifacts.find((a) => a.artifactType === 'AiAnalysisResult'); const arArtifact = artifacts.find((a) => a.artifactType === 'AiAnalysisResult');
expect(arArtifact).toBeDefined(); expect(arArtifact).toBeDefined();
@ -278,7 +282,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
const fiArtifacts = artifacts.filter((a) => a.artifactType === 'FocusItem'); const fiArtifacts = artifacts.filter((a) => a.artifactType === 'FocusItem');
expect(fiArtifacts.length).toBe(1); expect(fiArtifacts.length).toBe(1);
@ -294,7 +298,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
const result = tx.store.aiAnalysisResult[0]; const result = tx.store.aiAnalysisResult[0];
expect(result.userId).toBe('u-001'); expect(result.userId).toBe('u-001');
@ -312,7 +316,7 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
delete ctx.validatedOutput.score; delete ctx.validatedOutput.score;
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
expect(tx.store.aiAnalysisResult[0].masteryScore).toBeNull(); expect(tx.store.aiAnalysisResult[0].masteryScore).toBeNull();
}); });
}); });
@ -322,7 +326,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
const fi = tx.store.focusItem[0]; const fi = tx.store.focusItem[0];
expect(fi.title).toBe('X的应用条件'); expect(fi.title).toBe('X的应用条件');
@ -337,7 +341,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
const card = tx.store.reviewCard[0]; const card = tx.store.reviewCard[0];
expect(card.userId).toBe('u-001'); expect(card.userId).toBe('u-001');
@ -353,7 +357,7 @@ describe('ActiveRecallProjector', () => {
const ctx = makeContext(); const ctx = makeContext();
ctx.validatedOutput.reviewSuggestion.intervalDays = 400; ctx.validatedOutput.reviewSuggestion.intervalDays = 400;
await projector.project(tx as any, ctx); await projector.project(tx as unknown as PrismaTx, ctx);
const card = tx.store.reviewCard[0]; const card = tx.store.reviewCard[0];
expect(card.intervalDays).toBe(365); expect(card.intervalDays).toBe(365);
}); });
@ -364,7 +368,7 @@ describe('ActiveRecallProjector', () => {
const tx = createMockTx(); const tx = createMockTx();
const ctx = makeContext(); const ctx = makeContext();
const artifacts = await projector.project(tx as any, ctx); const artifacts = await projector.project(tx as unknown as PrismaTx, ctx);
const ordinals = artifacts.map((a) => a.ordinal); const ordinals = artifacts.map((a) => a.ordinal);
const uniqueOrdinals = [...new Set(ordinals)]; const uniqueOrdinals = [...new Set(ordinals)];

View File

@ -1,12 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry'; import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
import { ActiveRecallRegistrationService } from './active-recall-registration.service'; import { ActiveRecallRegistrationService } from './active-recall-registration.service';
import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition';
import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { JobDefinitionRegistry } from './job-definition-registry';
import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition';
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ActiveRecallRegistrationService // ActiveRecallRegistrationService

View File

@ -34,7 +34,7 @@ function makeProject() {
a.artifactId === data.artifactId, a.artifactId === data.artifactId,
); );
if (exists) { if (exists) {
const err = new Error('Unique constraint violation') as any; const err: Error & { code?: string } = new Error('Unique constraint violation');
err.code = 'P2002'; err.code = 'P2002';
throw err; throw err;
} }
@ -699,7 +699,8 @@ describe('FeynmanProjector', () => {
await projector.project(tx, ctx); await projector.project(tx, ctx);
// Verify artifact created for Child Job // Verify artifact created for Child Job
const childArtifactCreates = tx.aiJobArtifact.create.mock.calls.filter( const artifactCreateMock = tx.aiJobArtifact.create as jest.Mock;
const childArtifactCreates = artifactCreateMock.mock.calls.filter(
(call: any) => call[0].data.artifactType === 'ReviewCardChildJob', (call: any) => call[0].data.artifactType === 'ReviewCardChildJob',
); );
expect(childArtifactCreates.length).toBe(1); expect(childArtifactCreates.length).toBe(1);

View File

@ -79,6 +79,7 @@ export interface LearningAnalysisSnapshot {
coverageStart?: string; coverageStart?: string;
coverageEnd?: string; coverageEnd?: string;
insufficientDataReasons: string[]; insufficientDataReasons: string[];
overall: 'insufficient' | 'limited' | 'sufficient';
}; };
promptKey: string; promptKey: string;
promptVersion: string; promptVersion: string;

View File

@ -1,9 +1,20 @@
import { UserAiController } from './user-ai.controller'; import { UserAiController } from './user-ai.controller';
import { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router';
import { QuotaGuardService } from '../membership/quota-guard.service';
import type {
CreateAnalysisJobDto,
CreateCredentialDto,
SaveLearningProfileDto,
UpdateAiSettingsDto,
UpdateCredentialDto,
} from './user-ai.dto';
describe('UserAiController', () => { describe('UserAiController', () => {
let controller: UserAiController; let controller: UserAiController;
let mockService: any; let mockService: any;
let mockQuizRouter: any; let mockQuizRouter: any;
let mockLearningAnalysisRouter: LearningAnalysisExecutionRouter;
let mockQuotaGuard: QuotaGuardService;
beforeEach(() => { beforeEach(() => {
mockService = { mockService = {
@ -39,14 +50,27 @@ describe('UserAiController', () => {
mockQuizRouter = { mockQuizRouter = {
generateQuiz: jest.fn(), generateQuiz: jest.fn(),
}; };
const mockLearningAnalysisRouter = { mockLearningAnalysisRouter = new LearningAnalysisExecutionRouter(
analyze: jest.fn(), { isEnabled: jest.fn() } as never,
}; { build: jest.fn() } as never,
const mockQuotaGuard = { check: jest.fn().mockResolvedValue({ allowed: true, quotaType: 'test', limit: 30, used: 0, remaining: 30, resetAt: null, upgradeAvailable: false }), buildError: jest.fn().mockReturnValue({ errorCode: 'QUOTA_EXCEEDED', message: '', quotaType: 'test', limit: 30, used: 30, remaining: 0, resetAt: null, upgradeAvailable: true }), confirm: jest.fn().mockResolvedValue(undefined), cancel: jest.fn().mockResolvedValue(undefined) }; { createJob: jest.fn() } as never,
{ createAnalysisJob: jest.fn() } as never,
);
jest.spyOn(mockLearningAnalysisRouter, 'analyze').mockResolvedValue({
jobId: 'analysis-1',
status: 'queued',
engineMode: 'unified',
lifecycleStatus: 'queued',
});
mockQuotaGuard = new QuotaGuardService({ reserve: jest.fn() } as never);
jest.spyOn(mockQuotaGuard, 'check').mockResolvedValue({ allowed: true, quotaType: 'test', limit: 30, used: 0, remaining: 30, resetAt: null, upgradeAvailable: false });
jest.spyOn(mockQuotaGuard, 'buildError').mockReturnValue({ errorCode: 'QUOTA_EXCEEDED', message: '', quotaType: 'test', limit: 30, used: 30, remaining: 0, resetAt: null, upgradeAvailable: true });
jest.spyOn(mockQuotaGuard, 'confirm').mockResolvedValue(undefined);
jest.spyOn(mockQuotaGuard, 'cancel').mockResolvedValue(undefined);
controller = new UserAiController(mockService, mockQuizRouter, mockLearningAnalysisRouter, mockQuotaGuard); controller = new UserAiController(mockService, mockQuizRouter, mockLearningAnalysisRouter, mockQuotaGuard);
}); });
const req = (id = 'u1') => ({ user: { id } }) as any; const req = (id = 'u1') => ({ user: { id } });
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Profile // Profile
@ -67,9 +91,9 @@ describe('UserAiController', () => {
}); });
it('PUT profile delegates to service', async () => { it('PUT profile delegates to service', async () => {
const dto = { learningGoal: 'exam' }; const dto: SaveLearningProfileDto = { learningGoal: 'exam' };
mockService.saveProfile.mockResolvedValue({ id: 'p1' }); mockService.saveProfile.mockResolvedValue({ id: 'p1' });
const result = await controller.saveProfile(req(), dto as any); const result = await controller.saveProfile(req(), dto);
expect(mockService.saveProfile).toHaveBeenCalledWith('u1', dto); expect(mockService.saveProfile).toHaveBeenCalledWith('u1', dto);
expect(result).toEqual({ id: 'p1' }); expect(result).toEqual({ id: 'p1' });
}); });
@ -88,9 +112,9 @@ describe('UserAiController', () => {
}); });
it('PUT settings', async () => { it('PUT settings', async () => {
const dto = { allowAiAnalysis: false }; const dto: UpdateAiSettingsDto = { allowAiAnalysis: false };
mockService.updateSettings.mockResolvedValue({ id: 's1' }); mockService.updateSettings.mockResolvedValue({ id: 's1' });
const result = await controller.updateSettings(req(), dto as any); const result = await controller.updateSettings(req(), dto);
expect(mockService.updateSettings).toHaveBeenCalledWith('u1', dto); expect(mockService.updateSettings).toHaveBeenCalledWith('u1', dto);
expect(result).toEqual({ id: 's1' }); expect(result).toEqual({ id: 's1' });
}); });
@ -109,17 +133,17 @@ describe('UserAiController', () => {
}); });
it('POST model-credentials creates credential', async () => { it('POST model-credentials creates credential', async () => {
const dto = { name: 'MyKey', apiKey: 'sk-xxx' }; const dto: CreateCredentialDto = { provider: 'openai', apiKey: 'sk-xxx', keyAlias: 'MyKey' };
mockService.createCredential.mockResolvedValue({ id: 'c1' }); mockService.createCredential.mockResolvedValue({ id: 'c1' });
const result = await controller.createCredential(req(), dto as any); const result = await controller.createCredential(req(), dto);
expect(mockService.createCredential).toHaveBeenCalledWith('u1', dto); expect(mockService.createCredential).toHaveBeenCalledWith('u1', dto);
expect(result).toEqual({ id: 'c1' }); expect(result).toEqual({ id: 'c1' });
}); });
it('PUT model-credentials/:id updates credential', async () => { it('PUT model-credentials/:id updates credential', async () => {
const dto = { name: 'Updated' }; const dto: UpdateCredentialDto = { apiKey: 'sk-updated', keyAlias: 'Updated' };
mockService.updateCredential.mockResolvedValue({ id: 'c1' }); mockService.updateCredential.mockResolvedValue({ id: 'c1' });
const result = await controller.updateCredential(req(), 'c1', dto as any); const result = await controller.updateCredential(req(), 'c1', dto);
expect(mockService.updateCredential).toHaveBeenCalledWith('u1', 'c1', dto); expect(mockService.updateCredential).toHaveBeenCalledWith('u1', 'c1', dto);
expect(result).toEqual({ id: 'c1' }); expect(result).toEqual({ id: 'c1' });
}); });
@ -145,18 +169,18 @@ describe('UserAiController', () => {
describe('Analysis Jobs', () => { describe('Analysis Jobs', () => {
it('quiz_generation routes through QuizExecutionRouter', async () => { it('quiz_generation routes through QuizExecutionRouter', async () => {
const dto = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' }; const dto: CreateAnalysisJobDto = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' };
mockQuizRouter.generateQuiz.mockResolvedValue({ jobId: 'j1', status: 'queued' }); mockQuizRouter.generateQuiz.mockResolvedValue({ jobId: 'j1', status: 'queued' });
const result = await controller.createAnalysisJob(req(), dto as any); const result = await controller.createAnalysisJob(req(), dto);
expect(mockQuizRouter.generateQuiz).toHaveBeenCalledWith('u1', dto); expect(mockQuizRouter.generateQuiz).toHaveBeenCalledWith('u1', dto);
expect(mockService.createAnalysisJob).not.toHaveBeenCalled(); expect(mockService.createAnalysisJob).not.toHaveBeenCalled();
expect(result).toEqual({ jobId: 'j1', status: 'queued' }); expect(result).toEqual({ jobId: 'j1', status: 'queued' });
}); });
it('non-quiz/non-analysis jobType calls legacy service', async () => { it('non-quiz/non-analysis jobType calls legacy service', async () => {
const dto = { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' }; const dto: CreateAnalysisJobDto = { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' };
mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j2' }); mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j2' });
const result = await controller.createAnalysisJob(req(), dto as any); const result = await controller.createAnalysisJob(req(), dto);
expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto); expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto);
expect(mockQuizRouter.generateQuiz).not.toHaveBeenCalled(); expect(mockQuizRouter.generateQuiz).not.toHaveBeenCalled();
expect(result).toEqual({ jobId: 'j2' }); expect(result).toEqual({ jobId: 'j2' });

View File

@ -1,34 +1,20 @@
import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body, BadRequestException } from '@nestjs/common'; import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { DocumentImportService } from './document-import.service'; import { DocumentImportService } from './document-import.service';
import { CreateImportDto } from './dto/create-import.dto'; import { CreateImportDto } from './dto/create-import.dto';
import { QuotaGuardService } from '../membership/quota-guard.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('document-import') @ApiTags('document-import')
@Controller('imports') @Controller('imports')
export class DocumentImportController { export class DocumentImportController {
constructor( constructor(private readonly service: DocumentImportService) {}
private readonly service: DocumentImportService,
private readonly quotaGuard: QuotaGuardService,
) {}
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建导入任务' }) @ApiOperation({ summary: '创建导入任务' })
async createImport(@CurrentUser() user: UserPayload, @Body() dto: CreateImportDto) { async createImport(@CurrentUser() user: UserPayload, @Body() dto: CreateImportDto) {
const userId = user.id; return this.service.createImport({ ...dto, userId: user.id });
// M-MEMBER-01-CLOSE-04A: OCR/Vision 预检——导入创建时验证至少 1 页可用
// 逐页 reserve/commit/release 需 RAG Worker 侧实现Python
const opId = `import:${userId}:${Date.now()}`;
const ocrCheck = await this.quotaGuard.check(userId, 'ocr_pages', `${opId}:ocr`, 1);
const visionCheck = await this.quotaGuard.check(userId, 'vision_pages', `${opId}:vision`, 1);
if (!ocrCheck.allowed || !visionCheck.allowed) {
const blocked = !ocrCheck.allowed ? ocrCheck : visionCheck;
throw new BadRequestException({ errorCode: 'QUOTA_EXCEEDED', message: 'OCR/Vision quota exceeded', quotaType: blocked.quotaType, limit: blocked.limit, used: blocked.used, remaining: blocked.remaining, resetAt: blocked.resetAt, upgradeAvailable: blocked.upgradeAvailable });
}
return this.service.createImport({ ...dto, userId });
} }
@Get(':id/status') @Get(':id/status')

View File

@ -4,10 +4,8 @@ import { AdminImportsController } from './admin-imports.controller';
import { DocumentImportService } from './document-import.service'; import { DocumentImportService } from './document-import.service';
import { DocumentImportRepository } from './document-import.repository'; import { DocumentImportRepository } from './document-import.repository';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { MembershipModule } from '../membership/membership.module';
@Module({ @Module({
imports: [MembershipModule],
controllers: [DocumentImportController, AdminImportsController], controllers: [DocumentImportController, AdminImportsController],
providers: [DocumentImportService, DocumentImportRepository, PrismaService], providers: [DocumentImportService, DocumentImportRepository, PrismaService],
exports: [DocumentImportService, DocumentImportRepository], exports: [DocumentImportService, DocumentImportRepository],

View File

@ -2,18 +2,18 @@ import { Controller, Get, Delete, Param, Query, UseGuards } from '@nestjs/common
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { enrichWithNames } from '../../common/helpers/name-resolver'; import { enrichWithNames } from '../../common/helpers/name-resolver';
import { QueueService, QUEUE_FILE_CLEANUP } from '../../infrastructure/queue/queue.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
import type { AdminRole } from '../../common/types/admin-role.enum'; import type { AdminRole } from '../../common/types/admin-role.enum';
import { FilesService } from './files.service';
@ApiTags('admin-files') @ApiTags('admin-files')
@Controller('admin-api/files') @Controller('admin-api/files')
@UseGuards(AdminAuthGuard, AdminRolesGuard) @UseGuards(AdminAuthGuard, AdminRolesGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class AdminFilesController { export class AdminFilesController {
constructor(private readonly prisma: PrismaService, private readonly queue: QueueService) {} constructor(private readonly prisma: PrismaService, private readonly filesService: FilesService) {}
@Get() @Get()
@AdminRoles('SUPER_ADMIN' as AdminRole) @AdminRoles('SUPER_ADMIN' as AdminRole)
@ -36,11 +36,6 @@ export class AdminFilesController {
@AdminRoles('SUPER_ADMIN' as AdminRole) @AdminRoles('SUPER_ADMIN' as AdminRole)
@ApiOperation({ summary: '软删除文件' }) @ApiOperation({ summary: '软删除文件' })
async remove(@Param('id') id: string) { async remove(@Param('id') id: string) {
const file = await this.prisma.uploadedFile.findUnique({ where: { id } }); return this.filesService.deleteFileAsAdmin(id);
if (file?.objectKey) {
await this.queue.add(QUEUE_FILE_CLEANUP, { objectKey: file.objectKey, bucket: file.bucket, region: 'ap-beijing' });
}
await this.prisma.uploadedFile.delete({ where: { id } });
return { success: true };
} }
} }

View File

@ -2,9 +2,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional } from 'class-validator'; import { IsString, IsOptional } from 'class-validator';
export class CompleteUploadDto { export class CompleteUploadDto {
@ApiProperty({ description: 'COS 对象键(上传 URL 响应中返回)' }) @ApiProperty({ description: '上传会话 IDupload-url 响应中返回)' })
@IsString() @IsString()
objectKey: string; uploadId: string;
@ApiPropertyOptional({ description: 'COS 对象键(用于客户端幂等校验)' })
@IsOptional()
@IsString()
objectKey?: string;
@ApiPropertyOptional({ description: '文件 SHA256 校验和' }) @ApiPropertyOptional({ description: '文件 SHA256 校验和' })
@IsOptional() @IsOptional()

View File

@ -1,41 +1,22 @@
import { Controller, Get, Post, Delete, Body, Param, BadRequestException } from '@nestjs/common'; import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { CreateUploadUrlDto, CompleteUploadDto } from './dto'; import { CreateUploadUrlDto, CompleteUploadDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator'; import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator';
import { QuotaGuardService } from '../membership/quota-guard.service';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('files') @ApiTags('files')
@Controller('files') @Controller('files')
@ApiBearerAuth() @ApiBearerAuth()
export class FilesController { export class FilesController {
constructor( constructor(private readonly filesService: FilesService) {}
private readonly filesService: FilesService,
private readonly quotaGuard: QuotaGuardService,
) {}
@Post('upload-url') @Post('upload-url')
@FileUploadRateLimit() @FileUploadRateLimit()
@ApiOperation({ summary: '获取预签名上传 URL' }) @ApiOperation({ summary: '获取预签名上传 URL' })
async createUploadUrl(@CurrentUser() user: UserPayload, @Body() dto: CreateUploadUrlDto) { async createUploadUrl(@CurrentUser() user: UserPayload, @Body() dto: CreateUploadUrlDto) {
const userId = user.id; return this.filesService.requestUploadUrl(user.id, dto);
const opId = `upload:${userId}:${dto.filename}:${Date.now()}`;
// M-MEMBER-01-CLOSE-04B: file_size first check
const sizeCheck = await this.quotaGuard.check(userId, 'file_size_bytes', `${opId}:size`);
const declSize = (dto as any).sizeBytes ?? 0;
if (sizeCheck.limit > 0 && declSize > sizeCheck.limit) {
throw new BadRequestException({ errorCode: 'FILE_SIZE_LIMIT_EXCEEDED', message: 'File too large', quotaType: 'file_size_bytes', limit: sizeCheck.limit, used: 0, remaining: 0, resetAt: null, upgradeAvailable: true });
}
// Reserve storage bytes
const storageCheck = await this.quotaGuard.check(userId, 'storage_bytes', `${opId}:storage`, declSize);
if (!storageCheck.allowed) throw new BadRequestException(this.quotaGuard.buildError(storageCheck));
try {
const result = await this.filesService.requestUploadUrl(userId, dto);
return { ...result, _quotaOp: opId };
} catch { this.quotaGuard.cancel(`${opId}:storage`).catch(() => {}); throw new BadRequestException('Upload init failed'); }
} }
@Post('complete') @Post('complete')
@ -55,10 +36,6 @@ export class FilesController {
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: '删除文件COS + 数据库)' }) @ApiOperation({ summary: '删除文件COS + 数据库)' })
async deleteFile(@CurrentUser() user: UserPayload, @Param('id') id: string) { async deleteFile(@CurrentUser() user: UserPayload, @Param('id') id: string) {
const file = await this.filesService.getFile(user.id, id); return this.filesService.deleteFile(user.id, id);
const result = await this.filesService.deleteFile(user.id, id);
// M-MEMBER-01-CLOSE-04B: release storage on delete
if (file?.sizeBytes) this.quotaGuard.cancel(`upload:${user.id}:delete:${id}`).catch(() => {});
return result;
} }
} }

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class FilesRepository { export class FilesRepository {
@ -23,7 +24,7 @@ export class FilesRepository {
} }
async findByObjectKey(objectKey: string) { async findByObjectKey(objectKey: string) {
return this.prisma.uploadedFile.findFirst({ where: { objectKey } }); return this.prisma.uploadedFile.findUnique({ where: { objectKey } });
} }
async findByUserId(userId: string) { async findByUserId(userId: string) {
@ -36,4 +37,80 @@ export class FilesRepository {
async delete(id: string) { async delete(id: string) {
return this.prisma.uploadedFile.delete({ where: { id } }); return this.prisma.uploadedFile.delete({ where: { id } });
} }
async createUploadSession(data: {
id: string;
userId: string;
objectKey: string;
declaredSize: bigint;
reservedBytes: bigint;
quotaOperationId: string;
expiresAt: Date;
}) {
return this.prisma.uploadSession.create({ data });
}
async findUploadSessionById(id: string) {
return this.prisma.uploadSession.findUnique({
where: { id },
include: { uploadedFile: true },
});
}
async findExpiredUploadSessions(now: Date, userId?: string) {
return this.prisma.uploadSession.findMany({
where: {
status: { in: ['created', 'uploaded'] },
expiresAt: { lte: now },
...(userId ? { userId } : {}),
},
select: { id: true, objectKey: true },
});
}
async expireStaleUploadSessions(now: Date, userId?: string) {
return this.prisma.uploadSession.updateMany({
where: {
status: { in: ['created', 'uploaded'] },
expiresAt: { lte: now },
...(userId ? { userId } : {}),
},
data: { status: 'expired' },
});
}
async markUploadSessionFailed(id: string) {
return this.prisma.uploadSession.updateMany({
where: { id, status: { in: ['created', 'uploaded'] } },
data: { status: 'failed' },
});
}
async sumCommittedStorageBytes(userId: string, tx?: Prisma.TransactionClient) {
const client = tx ?? this.prisma;
const result = await client.uploadedFile.aggregate({
_sum: { sizeBytes: true },
where: { userId },
});
return Number(result._sum.sizeBytes ?? 0);
}
async sumReservedStorageBytes(
userId: string,
now: Date,
excludeSessionId?: string,
tx?: Prisma.TransactionClient,
) {
const client = tx ?? this.prisma;
const result = await client.uploadSession.aggregate({
_sum: { reservedBytes: true },
where: {
userId,
status: { in: ['created', 'uploaded'] },
expiresAt: { gt: now },
...(excludeSessionId ? { id: { not: excludeSessionId } } : {}),
},
});
return Number(result._sum.reservedBytes ?? 0);
}
} }

View File

@ -0,0 +1,494 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { FilesService } from './files.service';
describe('FilesService', () => {
type FilesServiceDeps = ConstructorParameters<typeof FilesService>;
const repository = {
expireStaleUploadSessions: jest.fn(),
findExpiredUploadSessions: jest.fn(),
sumCommittedStorageBytes: jest.fn(),
sumReservedStorageBytes: jest.fn(),
markUploadSessionFailed: jest.fn(),
findUploadSessionById: jest.fn(),
findById: jest.fn(),
delete: jest.fn(),
findByUserId: jest.fn(),
};
const storage = {
generateObjectKey: jest.fn(),
createUploadUrl: jest.fn(),
verifyUpload: jest.fn(),
deleteObject: jest.fn(),
getDownloadUrl: jest.fn(),
};
const cos = {
getBucket: jest.fn().mockReturnValue('bucket-1'),
};
const safety = {
check: jest.fn(),
};
const quotaService = {
computeEffectiveQuota: jest.fn(),
};
const membershipService = {
getEntitlements: jest.fn(),
};
const tx = {
uploadSession: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
uploadedFile: {
findUnique: jest.fn(),
create: jest.fn(),
},
};
const prisma = {
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: FilesService;
beforeEach(() => {
jest.clearAllMocks();
quotaService.computeEffectiveQuota.mockResolvedValue({
limits: [
{ quotaType: 'file_size_bytes', limit: 100, isUnlimited: false },
{ quotaType: 'storage_bytes', limit: 300, isUnlimited: false },
],
});
membershipService.getEntitlements.mockResolvedValue({ entitlements: null });
safety.check.mockResolvedValue({ safe: true });
storage.generateObjectKey.mockImplementation((userId: string, filename: string, uploadId: string) => (
`${userId}/202606/${uploadId}-${filename}`
));
storage.createUploadUrl.mockResolvedValue({
uploadUrl: 'https://example.com/upload',
objectKey: 'object-key',
bucket: 'bucket-1',
region: 'ap-shanghai',
expiresIn: 3600,
});
storage.deleteObject.mockResolvedValue(undefined);
repository.findExpiredUploadSessions.mockResolvedValue([]);
repository.sumCommittedStorageBytes.mockResolvedValue(50);
repository.sumReservedStorageBytes.mockResolvedValue(25);
tx.uploadSession.create.mockResolvedValue({});
tx.uploadSession.findUnique.mockResolvedValue(null);
tx.uploadSession.update.mockResolvedValue({});
tx.uploadedFile.findUnique.mockResolvedValue(null);
tx.uploadedFile.create.mockResolvedValue({
id: 'file-1',
userId: 'u1',
objectKey: 'object-key',
sizeBytes: 120,
});
service = new FilesService(
repository as unknown as FilesServiceDeps[0],
storage as unknown as FilesServiceDeps[1],
cos as unknown as FilesServiceDeps[2],
safety as unknown as FilesServiceDeps[3],
quotaService as unknown as FilesServiceDeps[4],
membershipService as unknown as FilesServiceDeps[5],
prisma as unknown as FilesServiceDeps[6],
);
});
it('rejects declared size over file limit before reserving storage', async () => {
await expect(service.requestUploadUrl('u1', {
filename: 'doc.pdf',
mimeType: 'application/pdf',
sizeBytes: 101,
})).rejects.toMatchObject({
response: expect.objectContaining({
errorCode: 'FILE_SIZE_LIMIT_EXCEEDED',
quotaType: 'file_size_bytes',
limit: 100,
}),
});
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(storage.createUploadUrl).not.toHaveBeenCalled();
});
it('creates upload session with stable storage operation id', async () => {
const result = await service.requestUploadUrl('u1', {
filename: 'doc.pdf',
mimeType: 'application/pdf',
sizeBytes: 80,
});
expect(result.uploadId).toEqual(expect.any(String));
expect(result.quotaOperationId).toBe(`storage-upload:${result.uploadId}`);
expect(tx.uploadSession.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
id: result.uploadId,
quotaOperationId: `storage-upload:${result.uploadId}`,
reservedBytes: BigInt(80),
declaredSize: BigInt(80),
status: 'created',
}),
}));
expect(storage.createUploadUrl).toHaveBeenCalledWith('u1', expect.objectContaining({
filename: 'doc.pdf',
objectKey: expect.stringContaining(`${result.uploadId}-doc.pdf`),
}), 3600);
});
it('completes upload with actual size lower than declared size', async () => {
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: null,
});
storage.verifyUpload.mockResolvedValue({
size: 60,
etag: 'etag',
contentType: 'application/pdf',
});
tx.uploadSession.findUnique.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
uploadedAt: null,
completedAt: null,
uploadedFile: null,
});
const result = await service.confirmUpload('u1', { uploadId: 'upload-1' });
expect(result).toMatchObject({ id: 'file-1' });
expect(tx.uploadedFile.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
objectKey: 'obj-1',
sizeBytes: 60,
}),
}));
expect(tx.uploadSession.update).toHaveBeenLastCalledWith(expect.objectContaining({
where: { id: 'upload-1' },
data: expect.objectContaining({
status: 'completed',
reservedBytes: BigInt(60),
}),
}));
});
it('completes upload with actual size higher than declared size when quota still allows it', async () => {
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: null,
});
storage.verifyUpload.mockResolvedValue({
size: 90,
etag: 'etag',
contentType: 'application/pdf',
});
repository.sumCommittedStorageBytes.mockResolvedValue(50);
repository.sumReservedStorageBytes.mockResolvedValue(20);
tx.uploadSession.findUnique.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
uploadedAt: null,
completedAt: null,
uploadedFile: null,
});
await service.confirmUpload('u1', { uploadId: 'upload-1' });
expect(tx.uploadedFile.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
sizeBytes: 90,
}),
}));
expect(tx.uploadSession.update).toHaveBeenLastCalledWith(expect.objectContaining({
data: expect.objectContaining({
reservedBytes: BigInt(90),
}),
}));
});
it('returns existing file on repeated complete without re-verifying provider state', async () => {
const uploadedFile = { id: 'file-1', userId: 'u1', objectKey: 'obj-1' };
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'completed',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile,
});
await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).resolves.toBe(uploadedFile);
expect(storage.verifyUpload).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('fails complete when actual file size exceeds limit and cleans up object', async () => {
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: null,
});
storage.verifyUpload.mockResolvedValue({
size: 150,
etag: 'etag',
contentType: 'application/pdf',
});
await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toMatchObject({
response: expect.objectContaining({
errorCode: 'FILE_SIZE_LIMIT_EXCEEDED',
}),
});
expect(repository.markUploadSessionFailed).toHaveBeenCalledWith('upload-1');
expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('blocks completion when actual size would push storage over limit', async () => {
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: null,
});
storage.verifyUpload.mockResolvedValue({
size: 90,
etag: 'etag',
contentType: 'application/pdf',
});
repository.sumCommittedStorageBytes.mockResolvedValue(120);
repository.sumReservedStorageBytes.mockResolvedValue(110);
tx.uploadSession.findUnique.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
uploadedAt: null,
completedAt: null,
uploadedFile: null,
});
await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toMatchObject({
response: expect.objectContaining({
errorCode: 'STORAGE_LIMIT_EXCEEDED',
quotaType: 'storage_bytes',
}),
});
expect(tx.uploadSession.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'upload-1' },
data: expect.objectContaining({ status: 'failed' }),
}));
expect(tx.uploadedFile.create).not.toHaveBeenCalled();
expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
});
it('marks session failed and cleans object when database write fails after provider verification', async () => {
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: null,
});
storage.verifyUpload.mockResolvedValue({
size: 80,
etag: 'etag',
contentType: 'application/pdf',
});
tx.uploadSession.findUnique.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
uploadedAt: null,
completedAt: null,
uploadedFile: null,
});
tx.uploadedFile.create.mockRejectedValueOnce(new Error('db failed'));
await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toThrow('db failed');
expect(repository.markUploadSessionFailed).toHaveBeenCalledWith('upload-1');
expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
});
it('rejects expired upload session before provider verification', async () => {
repository.findExpiredUploadSessions
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'upload-1', objectKey: 'obj-1' }]);
repository.findUploadSessionById.mockResolvedValue({
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
expiresAt: new Date(Date.now() - 1_000),
uploadedFile: null,
});
await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toBeInstanceOf(BadRequestException);
expect(storage.verifyUpload).not.toHaveBeenCalled();
expect(repository.findExpiredUploadSessions).toHaveBeenCalledTimes(2);
expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
});
it('returns same file for five concurrent complete requests', async () => {
const session = {
id: 'upload-1',
userId: 'u1',
objectKey: 'obj-1',
status: 'created',
uploadedAt: null as Date | null,
completedAt: null as Date | null,
uploadedFile: null as { id: string; userId: string; objectKey: string; sizeBytes: number } | null,
};
const persistedFile = {
id: 'file-1',
userId: 'u1',
objectKey: 'obj-1',
sizeBytes: 80,
};
let createdFileCount = 0;
repository.findUploadSessionById.mockImplementation(async () => ({
...session,
expiresAt: new Date(Date.now() + 60_000),
uploadedFile: session.uploadedFile,
}));
storage.verifyUpload.mockResolvedValue({
size: 80,
etag: 'etag',
contentType: 'application/pdf',
});
tx.uploadSession.findUnique.mockImplementation(async () => ({
...session,
uploadedFile: session.uploadedFile,
}));
tx.uploadedFile.findUnique.mockImplementation(async () => session.uploadedFile);
tx.uploadedFile.create.mockImplementation(async () => {
if (session.uploadedFile) {
const duplicateError: Error & { code?: string } = new Error('duplicate objectKey');
duplicateError.code = 'P2002';
throw duplicateError;
}
createdFileCount += 1;
session.uploadedFile = persistedFile;
return persistedFile;
});
tx.uploadSession.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => {
session.status = String(data.status ?? session.status);
session.uploadedAt = (data.uploadedAt as Date | undefined) ?? session.uploadedAt;
session.completedAt = (data.completedAt as Date | undefined) ?? session.completedAt;
return { ...session };
});
const results = await Promise.all(
Array.from({ length: 5 }, () => service.confirmUpload('u1', { uploadId: 'upload-1' })),
);
expect(createdFileCount).toBe(1);
expect(results).toHaveLength(5);
expect(results.every((item) => item.id === 'file-1')).toBe(true);
});
it('deletes object before removing file record', async () => {
repository.findById.mockResolvedValue({
id: 'file-1',
userId: 'u1',
objectKey: 'obj-1',
});
await expect(service.deleteFile('u1', 'file-1')).resolves.toEqual({ success: true });
expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
expect(repository.delete).toHaveBeenCalledWith('file-1');
});
it('allows repeated delete attempts to surface not found without double deleting storage', async () => {
repository.findById
.mockResolvedValueOnce({
id: 'file-1',
userId: 'u1',
objectKey: 'obj-1',
})
.mockResolvedValueOnce(null);
await expect(service.deleteFile('u1', 'file-1')).resolves.toEqual({ success: true });
await expect(service.deleteFile('u1', 'file-1')).rejects.toMatchObject({ message: '文件不存在' });
expect(storage.deleteObject).toHaveBeenCalledTimes(1);
expect(repository.delete).toHaveBeenCalledTimes(1);
});
it('admin delete removes object and file record', async () => {
repository.findById.mockResolvedValue({
id: 'file-1',
userId: 'u9',
objectKey: 'obj-admin-1',
});
await expect(service.deleteFileAsAdmin('file-1')).resolves.toEqual({ success: true });
expect(storage.deleteObject).toHaveBeenCalledWith('obj-admin-1');
expect(repository.delete).toHaveBeenCalledWith('file-1');
});
it('cleans expired upload sessions and orphaned objects before new upload url creation', async () => {
repository.findExpiredUploadSessions.mockResolvedValue([
{ id: 'expired-1', objectKey: 'orphan-1' },
{ id: 'expired-2', objectKey: 'orphan-2' },
]);
await service.requestUploadUrl('u1', {
filename: 'doc.pdf',
mimeType: 'application/pdf',
sizeBytes: 80,
});
expect(repository.expireStaleUploadSessions).toHaveBeenCalled();
expect(storage.deleteObject).toHaveBeenCalledWith('orphan-1');
expect(storage.deleteObject).toHaveBeenCalledWith('orphan-2');
});
it('forbids deleting another user file', async () => {
repository.findById.mockResolvedValue({
id: 'file-1',
userId: 'u2',
objectKey: 'obj-1',
});
await expect(service.deleteFile('u1', 'file-1')).rejects.toBeInstanceOf(ForbiddenException);
expect(storage.deleteObject).not.toHaveBeenCalled();
expect(repository.delete).not.toHaveBeenCalled();
});
});

View File

@ -1,13 +1,22 @@
import { import {
BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { FilesRepository } from './files.repository'; import { FilesRepository } from './files.repository';
import { StorageService } from '../../infrastructure/storage/storage.service'; import { StorageService } from '../../infrastructure/storage/storage.service';
import { CosStorageProvider } from '../../infrastructure/storage/cos-storage.provider'; import { CosStorageProvider } from '../../infrastructure/storage/cos-storage.provider';
import { ContentSafetyService } from '../content-safety/content-safety.service'; import { ContentSafetyService } from '../content-safety/content-safety.service';
import { CreateUploadUrlDto, CompleteUploadDto } from './dto'; import { CreateUploadUrlDto, CompleteUploadDto } from './dto';
import { EffectiveQuotaService } from '../membership/effective-quota.service';
import { EffectiveMembershipService } from '../membership/effective-membership.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
const ACTIVE_UPLOAD_SESSION_STATUSES = ['created', 'uploaded'] as const;
const UPLOAD_URL_EXPIRES_IN_SECONDS = 3600;
@Injectable() @Injectable()
export class FilesService { export class FilesService {
@ -16,6 +25,9 @@ export class FilesService {
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly cos: CosStorageProvider, private readonly cos: CosStorageProvider,
private readonly safety: ContentSafetyService, private readonly safety: ContentSafetyService,
private readonly quotaService: EffectiveQuotaService,
private readonly membershipService: EffectiveMembershipService,
private readonly prisma: PrismaService,
) {} ) {}
async requestUploadUrl(userId: string, dto: CreateUploadUrlDto) { async requestUploadUrl(userId: string, dto: CreateUploadUrlDto) {
@ -23,29 +35,175 @@ export class FilesService {
if (!check.safe) { if (!check.safe) {
throw new ForbiddenException('文件名包含违规内容'); throw new ForbiddenException('文件名包含违规内容');
} }
return this.storage.createUploadUrl(userId, {
await this.cleanupExpiredUploadSessions(userId);
await this.ensureFileSizeWithinLimit(userId, dto.sizeBytes);
const uploadId = randomUUID();
const objectKey = this.storage.generateObjectKey(userId, dto.filename, uploadId);
const quotaOperationId = `storage-upload:${uploadId}`;
await this.reserveStorageBytes({
userId,
uploadId,
objectKey,
declaredSize: dto.sizeBytes,
quotaOperationId,
});
try {
const result = await this.storage.createUploadUrl(userId, {
filename: dto.filename, filename: dto.filename,
mimeType: dto.mimeType, mimeType: dto.mimeType,
sizeBytes: dto.sizeBytes, sizeBytes: dto.sizeBytes,
}); objectKey,
}, UPLOAD_URL_EXPIRES_IN_SECONDS);
return {
uploadId,
quotaOperationId,
...result,
};
} catch (error) {
await this.repository.markUploadSessionFailed(uploadId);
throw error;
}
} }
async confirmUpload(userId: string, dto: CompleteUploadDto) { async confirmUpload(userId: string, dto: CompleteUploadDto) {
const info = await this.storage.verifyUpload(dto.objectKey); await this.cleanupExpiredUploadSessions(userId);
const parts = dto.objectKey.split('/'); const session = await this.repository.findUploadSessionById(dto.uploadId);
const originalFilename = parts[parts.length - 1]; if (!session) {
throw new NotFoundException('上传会话不存在');
}
if (session.userId !== userId) {
throw new ForbiddenException('无权操作该上传会话');
}
if (dto.objectKey && dto.objectKey !== session.objectKey) {
throw new BadRequestException('上传对象不匹配');
}
if (session.status === 'completed' && session.uploadedFile) {
return session.uploadedFile;
}
if (!ACTIVE_UPLOAD_SESSION_STATUSES.includes(session.status as (typeof ACTIVE_UPLOAD_SESSION_STATUSES)[number])) {
throw new BadRequestException('上传会话状态不可完成');
}
if (session.expiresAt <= new Date()) {
await this.cleanupExpiredUploadSessions(userId);
throw new BadRequestException('上传会话已过期');
}
return this.repository.create({ const info = await this.storage.verifyUpload(session.objectKey);
try {
await this.ensureFileSizeWithinLimit(userId, info.size);
} catch (error) {
await this.repository.markUploadSessionFailed(session.id);
await this.storage.deleteObject(session.objectKey).catch(() => {});
throw error;
}
try {
return await this.prisma.$transaction(async (tx) => {
const current = await tx.uploadSession.findUnique({
where: { id: dto.uploadId },
include: { uploadedFile: true },
});
if (!current) {
throw new NotFoundException('上传会话不存在');
}
if (current.userId !== userId) {
throw new ForbiddenException('无权操作该上传会话');
}
if (current.status === 'completed' && current.uploadedFile) {
return current.uploadedFile;
}
if (!ACTIVE_UPLOAD_SESSION_STATUSES.includes(current.status as (typeof ACTIVE_UPLOAD_SESSION_STATUSES)[number])) {
throw new BadRequestException('上传会话状态不可完成');
}
const existingFile = await tx.uploadedFile.findUnique({
where: { objectKey: current.objectKey },
});
if (existingFile) {
await tx.uploadSession.update({
where: { id: current.id },
data: {
status: 'completed',
uploadedAt: current.uploadedAt ?? new Date(),
completedAt: current.completedAt ?? new Date(),
uploadedFileId: existingFile.id,
reservedBytes: BigInt(info.size),
},
});
return existingFile;
}
const storageLimit = await this.getQuotaLimit(userId, 'storage_bytes');
const committedBytes = await this.repository.sumCommittedStorageBytes(userId, tx);
const reservedBytes = await this.repository.sumReservedStorageBytes(userId, new Date(), current.id, tx);
const totalAfterCommit = committedBytes + reservedBytes + info.size;
if (storageLimit !== null && totalAfterCommit > storageLimit) {
await tx.uploadSession.update({
where: { id: current.id },
data: { status: 'failed', uploadedAt: new Date() },
});
throw this.buildStorageLimitError(storageLimit, committedBytes + reservedBytes);
}
let uploadedFile;
try {
uploadedFile = await tx.uploadedFile.create({
data: {
userId, userId,
filename: originalFilename, filename: this.extractOriginalFilename(current.objectKey),
mimeType: info.contentType, mimeType: info.contentType,
storagePath: dto.objectKey, storagePath: current.objectKey,
objectKey: dto.objectKey, objectKey: current.objectKey,
bucket: this.cos.getBucket(), bucket: this.cos.getBucket(),
sizeBytes: info.size, sizeBytes: info.size,
checksum: dto.checksum, checksum: dto.checksum,
},
}); });
} catch (error) {
if (!this.isUniqueConstraintError(error)) {
throw error;
}
const existingAfterConflict = await tx.uploadedFile.findUnique({
where: { objectKey: current.objectKey },
});
if (!existingAfterConflict) {
throw error;
}
uploadedFile = existingAfterConflict;
}
await tx.uploadSession.update({
where: { id: current.id },
data: {
status: 'completed',
uploadedAt: new Date(),
completedAt: new Date(),
uploadedFileId: uploadedFile.id,
reservedBytes: BigInt(info.size),
},
});
return uploadedFile;
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
if (this.isStorageLimitError(error)) {
await this.storage.deleteObject(session.objectKey).catch(() => {});
} else if (!(error instanceof BadRequestException) && !(error instanceof ForbiddenException) && !(error instanceof NotFoundException)) {
await this.repository.markUploadSessionFailed(session.id);
await this.storage.deleteObject(session.objectKey).catch(() => {});
}
throw error;
}
} }
async getFile(userId: string, fileId: string) { async getFile(userId: string, fileId: string) {
@ -72,9 +230,145 @@ export class FilesService {
await this.storage.deleteObject(file.objectKey!); await this.storage.deleteObject(file.objectKey!);
await this.repository.delete(fileId); await this.repository.delete(fileId);
return { success: true };
}
async deleteFileAsAdmin(fileId: string) {
const file = await this.repository.findById(fileId);
if (!file) {
throw new NotFoundException('文件不存在');
}
await this.storage.deleteObject(file.objectKey!);
await this.repository.delete(fileId);
return { success: true };
} }
async findByUserId(userId: string) { async findByUserId(userId: string) {
return this.repository.findByUserId(userId); return this.repository.findByUserId(userId);
} }
async cleanupExpiredUploadSessions(userId?: string) {
const now = new Date();
const expiredSessions = await this.repository.findExpiredUploadSessions(now, userId);
if (expiredSessions.length === 0) {
return { expiredCount: 0, cleanedObjects: 0 };
}
await this.repository.expireStaleUploadSessions(now, userId);
let cleanedObjects = 0;
for (const session of expiredSessions) {
try {
await this.storage.deleteObject(session.objectKey);
cleanedObjects += 1;
} catch {
// Best-effort object cleanup; session has already been marked expired.
}
}
return { expiredCount: expiredSessions.length, cleanedObjects };
}
private async reserveStorageBytes(input: {
userId: string;
uploadId: string;
objectKey: string;
declaredSize: number;
quotaOperationId: string;
}) {
const now = new Date();
const expiresAt = new Date(now.getTime() + UPLOAD_URL_EXPIRES_IN_SECONDS * 1000);
await this.prisma.$transaction(async (tx) => {
const storageLimit = await this.getQuotaLimit(input.userId, 'storage_bytes');
const committedBytes = await this.repository.sumCommittedStorageBytes(input.userId, tx);
const reservedBytes = await this.repository.sumReservedStorageBytes(input.userId, now, undefined, tx);
const totalAfterReserve = committedBytes + reservedBytes + input.declaredSize;
if (storageLimit !== null && totalAfterReserve > storageLimit) {
throw this.buildStorageLimitError(storageLimit, committedBytes + reservedBytes);
}
await tx.uploadSession.create({
data: {
id: input.uploadId,
userId: input.userId,
objectKey: input.objectKey,
declaredSize: BigInt(input.declaredSize),
reservedBytes: BigInt(input.declaredSize),
quotaOperationId: input.quotaOperationId,
status: 'created',
expiresAt,
},
});
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
}
private async ensureFileSizeWithinLimit(userId: string, sizeBytes: number) {
const fileSizeLimit = await this.getQuotaLimit(userId, 'file_size_bytes');
if (fileSizeLimit !== null && sizeBytes > fileSizeLimit) {
throw new BadRequestException({
errorCode: 'FILE_SIZE_LIMIT_EXCEEDED',
message: 'File too large',
quotaType: 'file_size_bytes',
limit: fileSizeLimit,
used: 0,
remaining: 0,
resetAt: null,
upgradeAvailable: true,
});
}
}
private async getQuotaLimit(userId: string, quotaType: 'file_size_bytes' | 'storage_bytes') {
const quota = await this.quotaService.computeEffectiveQuota(userId);
const limit = quota.limits.find(item => item.quotaType === quotaType);
if (limit) {
return limit.isUnlimited ? null : limit.limit;
}
const entitlements = await this.membershipService.getEntitlements(userId);
if (!entitlements.entitlements) {
return null;
}
return quotaType === 'file_size_bytes'
? entitlements.entitlements.maxFileSizeBytes
: entitlements.entitlements.maxStorageBytes;
}
private buildStorageLimitError(limit: number, used: number) {
return new BadRequestException({
errorCode: 'STORAGE_LIMIT_EXCEEDED',
message: 'Storage quota exceeded',
quotaType: 'storage_bytes',
limit,
used,
remaining: Math.max(0, limit - used),
resetAt: null,
upgradeAvailable: true,
});
}
private isStorageLimitError(error: unknown) {
if (!(error instanceof BadRequestException)) {
return false;
}
const response = error.getResponse();
return typeof response === 'object' && response !== null && (response as { errorCode?: string }).errorCode === 'STORAGE_LIMIT_EXCEEDED';
}
private isUniqueConstraintError(error: unknown) {
return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002';
}
private extractOriginalFilename(objectKey: string) {
const parts = objectKey.split('/');
const storedName = parts[parts.length - 1] ?? objectKey;
const firstDash = storedName.indexOf('-');
return firstDash >= 0 ? storedName.slice(firstDash + 1) : storedName;
}
} }

View File

@ -5,11 +5,13 @@ import { KnowledgeBaseRepository } from './knowledge-base.repository';
import { SystemKnowledgeBaseSeed } from './system-kb.seed'; import { SystemKnowledgeBaseSeed } from './system-kb.seed';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { MembershipModule } from '../membership/membership.module'; import { MembershipModule } from '../membership/membership.module';
import { FilesModule } from '../files/files.module';
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
@Module({ @Module({
imports: [MembershipModule], imports: [MembershipModule, FilesModule],
controllers: [KnowledgeBaseController], controllers: [KnowledgeBaseController],
providers: [KnowledgeBaseService, KnowledgeBaseRepository, SystemKnowledgeBaseSeed, PrismaService], providers: [KnowledgeBaseService, KnowledgeBaseRepository, KnowledgeSourceRepository, SystemKnowledgeBaseSeed, PrismaService],
exports: [KnowledgeBaseService], exports: [KnowledgeBaseService],
}) })
export class KnowledgeBaseModule {} export class KnowledgeBaseModule {}

View File

@ -0,0 +1,99 @@
import { KnowledgeBaseService } from './knowledge-base.service';
describe('KnowledgeBaseService.remove', () => {
const repository = {
countByUserId: jest.fn(),
create: jest.fn(),
findAllByUserId: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
togglePin: jest.fn(),
setVisibility: jest.fn(),
subscribe: jest.fn(),
unsubscribe: jest.fn(),
findSubscribed: jest.fn(),
findAllPublic: jest.fn(),
};
const prisma = {
knowledgeFolder: {
findUnique: jest.fn(),
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
knowledgeItem: {
create: jest.fn(),
updateMany: jest.fn(),
},
};
const storage = {
getDownloadUrl: jest.fn(),
};
const sourceRepository = {
findFileSourcesByKnowledgeBase: jest.fn(),
softDeleteMany: jest.fn(),
};
const filesService = {
deleteFile: jest.fn(),
};
const safety = {
check: jest.fn(),
};
let service: KnowledgeBaseService;
beforeEach(() => {
jest.clearAllMocks();
repository.softDelete.mockResolvedValue(true);
sourceRepository.softDeleteMany.mockResolvedValue({ count: 2 });
filesService.deleteFile.mockResolvedValue({ success: true });
service = new KnowledgeBaseService(
repository as never,
prisma as never,
storage as never,
sourceRepository as never,
filesService as never,
safety as never,
);
});
it('deletes linked files and soft deletes file sources before deleting knowledge base', async () => {
repository.findById.mockResolvedValue({
id: 'kb-1',
userId: 'u1',
});
sourceRepository.findFileSourcesByKnowledgeBase.mockResolvedValue([
{ id: 'source-1', fileId: 'file-1' },
{ id: 'source-2', fileId: 'file-2' },
]);
await service.remove('u1', 'kb-1');
expect(filesService.deleteFile).toHaveBeenNthCalledWith(1, 'u1', 'file-1');
expect(filesService.deleteFile).toHaveBeenNthCalledWith(2, 'u1', 'file-2');
expect(sourceRepository.softDeleteMany).toHaveBeenCalledWith(['source-1', 'source-2']);
expect(repository.softDelete).toHaveBeenCalledWith('kb-1');
});
it('still deletes knowledge base when there are no linked file sources', async () => {
repository.findById.mockResolvedValue({
id: 'kb-1',
userId: 'u1',
});
sourceRepository.findFileSourcesByKnowledgeBase.mockResolvedValue([]);
await service.remove('u1', 'kb-1');
expect(filesService.deleteFile).not.toHaveBeenCalled();
expect(sourceRepository.softDeleteMany).toHaveBeenCalledWith([]);
expect(repository.softDelete).toHaveBeenCalledWith('kb-1');
});
});

View File

@ -4,6 +4,8 @@ import { PrismaService } from '../../infrastructure/database/prisma.service';
import { ContentSafetyService } from '../content-safety/content-safety.service'; import { ContentSafetyService } from '../content-safety/content-safety.service';
import { StorageService } from '../../infrastructure/storage/storage.service'; import { StorageService } from '../../infrastructure/storage/storage.service';
import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants'; import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants';
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
import { FilesService } from '../files/files.service';
@Injectable() @Injectable()
export class KnowledgeBaseService { export class KnowledgeBaseService {
@ -11,6 +13,8 @@ export class KnowledgeBaseService {
private readonly repository: KnowledgeBaseRepository, private readonly repository: KnowledgeBaseRepository,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly sourceRepository: KnowledgeSourceRepository,
private readonly filesService: FilesService,
@Optional() private readonly safety?: ContentSafetyService, @Optional() private readonly safety?: ContentSafetyService,
) {} ) {}
@ -71,6 +75,15 @@ export class KnowledgeBaseService {
if (!kb || String(kb.userId) !== userId) { if (!kb || String(kb.userId) !== userId) {
throw new NotFoundException('知识库不存在'); throw new NotFoundException('知识库不存在');
} }
const fileSources = await this.sourceRepository.findFileSourcesByKnowledgeBase(id);
for (const source of fileSources) {
if (source.fileId) {
await this.filesService.deleteFile(userId, source.fileId);
}
}
await this.sourceRepository.softDeleteMany(fileSources.map(source => source.id));
return this.repository.softDelete(id); return this.repository.softDelete(id);
} }

View File

@ -44,6 +44,6 @@ export class KnowledgeSourceController {
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: '删除资料来源' }) @ApiOperation({ summary: '删除资料来源' })
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {
return this.service.remove(id); return this.service.remove(user.id, id);
} }
} }

View File

@ -3,9 +3,10 @@ import { KnowledgeSourceController } from './knowledge-source.controller';
import { KnowledgeSourceService } from './knowledge-source.service'; import { KnowledgeSourceService } from './knowledge-source.service';
import { KnowledgeSourceRepository } from './knowledge-source.repository'; import { KnowledgeSourceRepository } from './knowledge-source.repository';
import { DocumentImportModule } from '../document-import/document-import.module'; import { DocumentImportModule } from '../document-import/document-import.module';
import { FilesModule } from '../files/files.module';
@Module({ @Module({
imports: [DocumentImportModule], imports: [DocumentImportModule, FilesModule],
controllers: [KnowledgeSourceController], controllers: [KnowledgeSourceController],
providers: [KnowledgeSourceService, KnowledgeSourceRepository], providers: [KnowledgeSourceService, KnowledgeSourceRepository],
exports: [KnowledgeSourceService, KnowledgeSourceRepository], exports: [KnowledgeSourceService, KnowledgeSourceRepository],

View File

@ -47,6 +47,21 @@ export class KnowledgeSourceRepository {
return this.prisma.knowledgeSource.findFirst({ where: { id, deletedAt: null } }); return this.prisma.knowledgeSource.findFirst({ where: { id, deletedAt: null } });
} }
async findByIdIncludingDeleted(id: string) {
return this.prisma.knowledgeSource.findUnique({ where: { id } });
}
async findFileSourcesByKnowledgeBase(knowledgeBaseId: string) {
return this.prisma.knowledgeSource.findMany({
where: {
knowledgeBaseId,
deletedAt: null,
fileId: { not: null },
},
select: { id: true, fileId: true },
});
}
async softDelete(id: string) { async softDelete(id: string) {
return this.prisma.knowledgeSource.update({ return this.prisma.knowledgeSource.update({
where: { id }, where: { id },
@ -54,6 +69,16 @@ export class KnowledgeSourceRepository {
}); });
} }
async softDeleteMany(ids: string[]) {
if (ids.length === 0) {
return { count: 0 };
}
return this.prisma.knowledgeSource.updateMany({
where: { id: { in: ids }, deletedAt: null },
data: { deletedAt: new Date() },
});
}
async updateParseStatus(id: string, parseStatus: string, data?: { textLength?: number; parsedObjectKey?: string; metadataObjectKey?: string; errorCode?: string; errorMessage?: string }) { async updateParseStatus(id: string, parseStatus: string, data?: { textLength?: number; parsedObjectKey?: string; metadataObjectKey?: string; errorCode?: string; errorMessage?: string }) {
return this.prisma.knowledgeSource.update({ return this.prisma.knowledgeSource.update({
where: { id }, where: { id },

View File

@ -0,0 +1,68 @@
import { KnowledgeSourceService } from './knowledge-source.service';
describe('KnowledgeSourceService.remove', () => {
const repository = {
create: jest.fn(),
findByKnowledgeBase: jest.fn(),
findById: jest.fn(),
softDelete: jest.fn(),
updateParseStatus: jest.fn(),
updateIndexStatus: jest.fn(),
};
const importRepo = {
create: jest.fn(),
};
const queue = {
add: jest.fn(),
};
const redis = {
set: jest.fn(),
};
const filesService = {
deleteFile: jest.fn(),
};
let service: KnowledgeSourceService;
beforeEach(() => {
jest.clearAllMocks();
repository.softDelete.mockResolvedValue({ id: 'source-1', deletedAt: new Date() });
filesService.deleteFile.mockResolvedValue({ success: true });
service = new KnowledgeSourceService(
repository as never,
importRepo as never,
queue as never,
redis as never,
filesService as never,
);
});
it('deletes linked uploaded file before soft deleting source', async () => {
repository.findById.mockResolvedValue({
id: 'source-1',
fileId: 'file-1',
});
await service.remove('u1', 'source-1');
expect(filesService.deleteFile).toHaveBeenCalledWith('u1', 'file-1');
expect(repository.softDelete).toHaveBeenCalledWith('source-1');
});
it('soft deletes source without touching files when no file is linked', async () => {
repository.findById.mockResolvedValue({
id: 'source-1',
fileId: null,
});
await service.remove('u1', 'source-1');
expect(filesService.deleteFile).not.toHaveBeenCalled();
expect(repository.softDelete).toHaveBeenCalledWith('source-1');
});
});

View File

@ -3,6 +3,7 @@ import { KnowledgeSourceRepository } from './knowledge-source.repository';
import { DocumentImportRepository } from '../document-import/document-import.repository'; import { DocumentImportRepository } from '../document-import/document-import.repository';
import { QueueService } from '../../infrastructure/queue/queue.service'; import { QueueService } from '../../infrastructure/queue/queue.service';
import { RedisService } from '../../infrastructure/redis/redis.service'; import { RedisService } from '../../infrastructure/redis/redis.service';
import { FilesService } from '../files/files.service';
@Injectable() @Injectable()
export class KnowledgeSourceService { export class KnowledgeSourceService {
@ -11,6 +12,7 @@ export class KnowledgeSourceService {
private readonly importRepo: DocumentImportRepository, private readonly importRepo: DocumentImportRepository,
private readonly queue: QueueService, private readonly queue: QueueService,
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly filesService: FilesService,
) {} ) {}
async addSource(userId: string, knowledgeBaseId: string, dto: { async addSource(userId: string, knowledgeBaseId: string, dto: {
@ -62,9 +64,12 @@ export class KnowledgeSourceService {
return source; return source;
} }
async remove(id: string) { async remove(userId: string, id: string) {
const source = await this.repository.findById(id); const source = await this.repository.findById(id);
if (!source) throw new NotFoundException('资料来源不存在'); if (!source) throw new NotFoundException('资料来源不存在');
if (source.fileId) {
await this.filesService.deleteFile(userId, source.fileId);
}
return this.repository.softDelete(id); return this.repository.softDelete(id);
} }

View File

@ -24,8 +24,8 @@ describe('MembershipController', () => {
svc.compute.mockResolvedValue({ effectivePlan:'free', grants:[], appAccountToken:null }); svc.compute.mockResolvedValue({ effectivePlan:'free', grants:[], appAccountToken:null });
svc.getEntitlements.mockResolvedValue({ planCode:'free', entitlements:{ maxKnowledgeBases:1, maxStorageBytes:1073741824, maxFileSizeBytes:10485760, monthlyOcrPages:0, monthlyVisionPages:0, monthlyChatCount:30, monthlyAiAnalysisCount:0, monthlyRecallCount:0, monthlyCardGenCount:0, monthlyQuizCount:0 } }); svc.getEntitlements.mockResolvedValue({ planCode:'free', entitlements:{ maxKnowledgeBases:1, maxStorageBytes:1073741824, maxFileSizeBytes:10485760, monthlyOcrPages:0, monthlyVisionPages:0, monthlyChatCount:30, monthlyAiAnalysisCount:0, monthlyRecallCount:0, monthlyCardGenCount:0, monthlyQuizCount:0 } });
const r = await ctrl.getMyMembership({ user:{ id:'u1' } }); const r = await ctrl.getMyMembership({ user:{ id:'u1' } });
expect(r.transactionId).toBeUndefined(); expect(r).not.toHaveProperty('transactionId');
expect(r.originalTransactionId).toBeUndefined(); expect(r).not.toHaveProperty('originalTransactionId');
}); });
it('GET /membership/plans returns active plans', async () => { it('GET /membership/plans returns active plans', async () => {

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { QuotaGuardService } from './quota-guard.service'; import { QuotaCheckResult, QuotaGuardService } from './quota-guard.service';
import { EffectiveQuotaService } from './effective-quota.service'; import { EffectiveQuotaService } from './effective-quota.service';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
@ -7,8 +7,8 @@ describe('QuotaGuardService', () => {
let guard: QuotaGuardService; let guard: QuotaGuardService;
let mockQuota: any; let mockQuota: any;
const allowed = { allowed:true, quotaType:'quiz_generation', limit:30, used:5, remaining:25, resetAt: null, upgradeAvailable:false }; const allowed: QuotaCheckResult = { allowed:true, quotaType:'quiz_generation', limit:30, used:5, remaining:25, resetAt: null, upgradeAvailable:false };
const denied = { allowed:false, quotaType:'quiz_generation', limit:30, used:31, remaining:0, resetAt: null, upgradeAvailable:true }; const denied: QuotaCheckResult = { allowed:false, quotaType:'quiz_generation', limit:30, used:31, remaining:0, resetAt: null, upgradeAvailable:true };
beforeEach(async () => { beforeEach(async () => {
mockQuota = { mockQuota = {
@ -31,6 +31,7 @@ describe('QuotaGuardService', () => {
const r = await guard.check('u1', 'quiz_generation', 'op1'); const r = await guard.check('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(false); expect(r.allowed).toBe(false);
expect(r.upgradeAvailable).toBe(true); expect(r.upgradeAvailable).toBe(true);
expect(typeof r.resetAt).toBe('string');
}); });
it('buildError returns QUOTA_EXCEEDED for ai types', () => { it('buildError returns QUOTA_EXCEEDED for ai types', () => {

View File

@ -0,0 +1,106 @@
import { UsersService } from './users.service';
describe('UsersService.getStorage', () => {
const usersRepository = {
findProfileByUserId: jest.fn(),
updateProfile: jest.fn(),
findUserProfile: jest.fn(),
upsertUserProfile: jest.fn(),
updatePreferences: jest.fn(),
};
const prisma = {
uploadedFile: {
findMany: jest.fn(),
},
uploadSession: {
findMany: jest.fn(),
},
userMembership: {
findFirst: jest.fn(),
},
accountDeletionRequest: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
userDevice: {
findMany: jest.fn(),
deleteMany: jest.fn(),
},
knowledgeBase: {
count: jest.fn(),
},
knowledgeItem: {
count: jest.fn(),
},
reviewCard: {
count: jest.fn(),
},
};
const membershipService = {
compute: jest.fn(),
getEntitlements: jest.fn(),
};
const quotaService = {
computeEffectiveQuota: jest.fn(),
};
let service: UsersService;
beforeEach(() => {
jest.clearAllMocks();
prisma.uploadedFile.findMany.mockResolvedValue([
{ sizeBytes: BigInt(100), mimeType: 'application/pdf' },
{ sizeBytes: BigInt(50), mimeType: 'image/png' },
]);
prisma.uploadSession.findMany.mockResolvedValue([
{ reservedBytes: BigInt(20) },
{ reservedBytes: BigInt(30) },
]);
quotaService.computeEffectiveQuota.mockResolvedValue({
limits: [{ quotaType: 'storage_bytes', limit: 500, isUnlimited: false }],
});
membershipService.getEntitlements.mockResolvedValue({
entitlements: { maxStorageBytes: 700 },
});
service = new UsersService(
usersRepository as never,
prisma as never,
membershipService as never,
quotaService as never,
);
});
it('returns committed bytes plus active reservations as usedBytes', async () => {
const result = await service.getStorage('u1');
expect(result).toEqual({
totalBytes: 500,
usedBytes: 200,
fileCount: 2,
});
});
it('falls back to membership entitlements when quota lookup fails', async () => {
quotaService.computeEffectiveQuota.mockRejectedValue(new Error('quota unavailable'));
const result = await service.getStorage('u1');
expect(result.totalBytes).toBe(700);
expect(result.usedBytes).toBe(200);
});
it('returns null totalBytes when neither quota nor membership entitlement is available', async () => {
quotaService.computeEffectiveQuota.mockRejectedValue(new Error('quota unavailable'));
membershipService.getEntitlements.mockResolvedValue({ entitlements: null });
const result = await service.getStorage('u1');
expect(result.totalBytes).toBeNull();
expect(result.usedBytes).toBe(200);
});
});

View File

@ -87,19 +87,38 @@ export class UsersService {
// ── Storage ── // ── Storage ──
async getStorage(userId: string) { async getStorage(userId: string) {
const files = await this.prisma.uploadedFile.findMany({ const now = new Date();
const [files, pendingReservations] = await Promise.all([
this.prisma.uploadedFile.findMany({
where: { userId }, where: { userId },
select: { sizeBytes: true, mimeType: true }, select: { sizeBytes: true, mimeType: true },
}); }),
const usedBytes = files.reduce((sum, f) => sum + Number(f.sizeBytes), 0); this.prisma.uploadSession.findMany({
// M-MEMBER-01-CLOSE-01E: 从 EffectiveQuota 读取真实存储配额,不再硬编码 1GB where: {
let totalBytes = 1073741824; // fallbackquota 不可用时) userId,
status: { in: ['created', 'uploaded'] },
expiresAt: { gt: now },
},
select: { reservedBytes: true },
}),
]);
const committedBytes = files.reduce((sum, f) => sum + Number(f.sizeBytes), 0);
const reservedBytes = pendingReservations.reduce((sum, item) => sum + Number(item.reservedBytes), 0);
const usedBytes = committedBytes + reservedBytes;
let totalBytes: number | null = null;
if (this.quotaService) { if (this.quotaService) {
try { try {
const q = await this.quotaService.computeEffectiveQuota(userId); const q = await this.quotaService.computeEffectiveQuota(userId);
const storageLimit = q.limits.find(l => l.quotaType === 'storage_bytes'); const storageLimit = q.limits.find(l => l.quotaType === 'storage_bytes');
if (storageLimit && !storageLimit.isUnlimited) totalBytes = storageLimit.limit; if (storageLimit && !storageLimit.isUnlimited) totalBytes = storageLimit.limit;
} catch { /* keep fallback */ } } catch { /* fall through */ }
}
if (totalBytes === null && this.membershipService) {
try {
const entitlements = await this.membershipService.getEntitlements(userId);
totalBytes = entitlements.entitlements?.maxStorageBytes ?? null;
} catch { /* leave null */ }
} }
return { totalBytes, usedBytes, fileCount: files.length }; return { totalBytes, usedBytes, fileCount: files.length };
} }

View File

@ -1,8 +1,9 @@
{ {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..", "rootDir": "..",
"roots": ["<rootDir>/test"],
"testEnvironment": "node", "testEnvironment": "node",
"testRegex": ".e2e-spec.ts$", "testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }] "^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }]
}, },

View File

@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testEnvironment": "node",
"roots": ["<rootDir>/test", "<rootDir>/src"],
"testRegex": ".*(\\.integration-spec|\\.integration\\.spec|\\.worker-int-spec)\\.ts$",
"transform": {
"^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }]
},
"transformIgnorePatterns": ["/node_modules/"],
"testTimeout": 120000
}

20
test/jest-unit.json Normal file
View File

@ -0,0 +1,20 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"roots": ["<rootDir>/src"],
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": [
"\\.integration-spec\\.ts$",
"\\.integration\\.spec\\.ts$",
"\\.worker-int-spec\\.ts$",
"\\.e2e-spec\\.ts$"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"src/**/*.(t|j)s"
],
"coverageDirectory": "./coverage"
}

View File

@ -1,54 +1,42 @@
#!/bin/bash #!/bin/bash
# M-AI-01-09 真实集成测试 CI Runner # Integration test runner
# 启动真实 MySQL/Redis/API/Worker/Mock AI Provider 并执行集成测试 # Runs only integration suites under test/:
# - *.integration-spec.ts
# - *.worker-int-spec.ts
# Requires real MySQL, Redis and BullMQ worker dependencies.
set -e set -e
echo "=== M-AI-01-09 真实集成测试 ===" echo "=== Integration tests ==="
DB_URL="${DATABASE_URL:-mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test}" DB_URL="${DATABASE_URL:-mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test}"
REDIS_H="${REDIS_HOST:-127.0.0.1}" REDIS_H="${REDIS_HOST:-127.0.0.1}"
REDIS_P="${REDIS_PORT:-6379}" REDIS_P="${REDIS_PORT:-6379}"
REDIS_PW="${REDIS_PASSWORD:-}" REDIS_PW="${REDIS_PASSWORD:-}"
# 1. Check MySQL (via docker exec since mysqladmin may not be in CI PATH) echo "[1/4] Checking MySQL..."
echo "[1/7] Checking MySQL..." if command -v mysqladmin >/dev/null 2>&1; then
docker exec mysql mysqladmin ping -u root -p"${MYSQL_ROOT_PASSWORD:-root}" --silent 2>/dev/null || { mysqladmin ping --host="${MYSQL_HOST:-127.0.0.1}" --port="${MYSQL_PORT:-3306}" --user="${MYSQL_USER:-root}" --password="${MYSQL_PASSWORD:-${MYSQL_ROOT_PASSWORD:-root}}" --silent >/dev/null
echo "MySQL not reachable via docker; ensure MySQL container is running" else
docker exec mysql mysqladmin ping -u root -p"${MYSQL_ROOT_PASSWORD:-root}" --silent >/dev/null 2>&1 || {
echo "MySQL not reachable; ensure MySQL is running"
exit 1 exit 1
} }
# 2. Prisma migration
echo "[2/7] Running Prisma migration..."
npx prisma migrate deploy
# 3. Build
echo "[3/7] Building..."
npm run build --if-present
# 4. Start Mock AI Provider is handled internally by the test
# 5. Run integration tests (test starts its own API/Worker/Mock Provider)
echo "[4/7] Running Worker Integration tests (BullMQ → Worker → MySQL)..."
# Worker integration test: M-AI-01-09 requires Worker process to start within 30s,
# which depends on BullMQ/Redis connection that may not be stable in CI.
# Known flaky — output captured but failure does not block deploy.
# TODO: fix Worker startup timeout and re-enable blocking.
npx jest --config test/jest-worker-integration.json --forceExit --verbose \
--testPathPatterns='worker-int-spec' --testPathIgnorePatterns='api-e2e' 2>&1 || echo "[worker-int] tests have known Worker timeout issue — see comment above"
echo ""
echo "[5/7] Running API E2E tests (HTTP → Controller → BullMQ → Worker → MySQL)..."
# API E2E requires JWT_SECRET match between test token gen and spawned API process.
# Known issue: test harness spawns API with correct JWT_SECRET but JwtAuthGuard rejects.
# Production API verified manually with real token (Feynman=201, Import=201, ActiveRecall=201).
# Run with || true to not block CI; uncomment exit 1 when JWT issue resolved.
npx jest --config test/jest-worker-integration.json --forceExit --verbose \
--testPathPatterns='api-e2e' 2>&1 || echo "[api-e2e] tests have known JWT issue — see comment above"
TEST_EXIT=$?
echo ""
if [ $TEST_EXIT -eq 0 ]; then
echo "=== M-AI-01-09: PASS ==="
else
echo "=== M-AI-01-09: FAIL ==="
fi fi
exit $TEST_EXIT echo "[2/4] Checking Redis..."
if command -v redis-cli >/dev/null 2>&1; then
if [ -n "$REDIS_PW" ]; then
redis-cli -h "$REDIS_H" -p "$REDIS_P" -a "$REDIS_PW" ping | grep -q PONG
else
redis-cli -h "$REDIS_H" -p "$REDIS_P" ping | grep -q PONG
fi
else
echo "redis-cli not found; skipping explicit Redis ping, but integration tests still require Redis"
fi
echo "[3/4] Running Prisma migration..."
npx prisma migrate deploy
echo "[4/4] Running integration suites..."
npm run build --if-present
npx jest --config test/jest-integration.json --runInBand --verbose