From 05cf369bee61fab6e1674c7bd767019514717fa9 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 27 Jun 2026 12:42:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E5=9C=B0=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=20+=20Python=203.9=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 docker-compose.local.yml(MySQL/Redis/Qdrant 本地基础设施) - 添加 mysql.conf.d 本地 MySQL 低资源配置 - RAG Worker Python 3.9 兼容(from __future__ import annotations) - 同步最新业务代码变更 --- docker-compose.local.yml | 51 ++ mysql.conf.d/zhixi.cnf | 14 + package.json | 18 +- prisma/schema.prisma | 26 +- rag-worker/api_client.py | 2 + rag-worker/chunker.py | 2 + src/infrastructure/storage/storage.service.ts | 17 +- .../ai-job/active-recall-projector.spec.ts | 56 +- ...active-recall-registration.service.spec.ts | 4 +- src/modules/ai-job/feynman-projector.spec.ts | 5 +- .../learning-analysis-snapshot-builder.ts | 1 + .../ai-runtime/user-ai.controller.spec.ts | 58 +- .../document-import.controller.ts | 20 +- .../document-import/document-import.module.ts | 2 - src/modules/files/admin-files.controller.ts | 11 +- src/modules/files/dto/complete-upload.dto.ts | 9 +- src/modules/files/files.controller.ts | 33 +- src/modules/files/files.repository.ts | 79 ++- src/modules/files/files.service.spec.ts | 494 ++++++++++++++++++ src/modules/files/files.service.ts | 328 +++++++++++- .../knowledge-base/knowledge-base.module.ts | 6 +- .../knowledge-base.service.spec.ts | 99 ++++ .../knowledge-base/knowledge-base.service.ts | 13 + .../knowledge-source.controller.ts | 2 +- .../knowledge-source.module.ts | 3 +- .../knowledge-source.repository.ts | 25 + .../knowledge-source.service.spec.ts | 68 +++ .../knowledge-source.service.ts | 7 +- .../membership/membership.controller.spec.ts | 4 +- .../membership/quota-guard.service.spec.ts | 7 +- src/modules/users/users.service.spec.ts | 106 ++++ src/modules/users/users.service.ts | 35 +- test/jest-e2e.json | 3 +- test/jest-integration.json | 12 + test/jest-unit.json | 20 + .../m-ai-03-synthetic.integration-spec.ts | 0 test/run-integration-tests.sh | 72 ++- 37 files changed, 1519 insertions(+), 193 deletions(-) create mode 100644 docker-compose.local.yml create mode 100644 mysql.conf.d/zhixi.cnf create mode 100644 src/modules/files/files.service.spec.ts create mode 100644 src/modules/knowledge-base/knowledge-base.service.spec.ts create mode 100644 src/modules/knowledge-source/knowledge-source.service.spec.ts create mode 100644 src/modules/users/users.service.spec.ts create mode 100644 test/jest-integration.json create mode 100644 test/jest-unit.json rename src/modules/ai-job/m-ai-03-synthetic.integration.spec.ts => test/m-ai-03-synthetic.integration-spec.ts (100%) diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..93f28c0 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,51 @@ +# 知习 本地开发环境 Docker Compose +# 只包含基础设施(MySQL/Redis/Qdrant),API/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: diff --git a/mysql.conf.d/zhixi.cnf b/mysql.conf.d/zhixi.cnf new file mode 100644 index 0000000..65d5d16 --- /dev/null +++ b/mysql.conf.d/zhixi.cnf @@ -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 diff --git a/package.json b/package.json index 2acf3b7..3acf10c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start:prod": "node dist/src/main", "start:worker": "node dist/src/worker.main", "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:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", @@ -88,15 +89,24 @@ "json", "ts" ], - "rootDir": "src", + "rootDir": ".", + "roots": [ + "/src" + ], "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": [ + "\\.integration-spec\\.ts$", + "\\.integration\\.spec\\.ts$", + "\\.worker-int-spec\\.ts$", + "\\.e2e-spec\\.ts$" + ], "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "src/**/*.(t|j)s" ], - "coverageDirectory": "../coverage", + "coverageDirectory": "./coverage", "testEnvironment": "node" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a728a8..76825c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model User { knowledgeItemRelations KnowledgeItemRelation[] tags Tag[] uploadedFiles UploadedFile[] + uploadSessions UploadSession[] documentImports DocumentImport[] learningSessions LearningSession[] learningRecords LearningRecord[] @@ -330,7 +331,7 @@ model UploadedFile { filename String @db.VarChar(255) mimeType String? @db.VarChar(100) storagePath String @db.VarChar(500) - objectKey String? @db.VarChar(500) + objectKey String? @unique @db.VarChar(500) bucket String? @db.VarChar(100) sizeBytes BigInt @default(0) checksum String? @db.VarChar(255) @@ -340,12 +341,35 @@ model UploadedFile { user User @relation(fields: [userId], references: [id]) sources KnowledgeSource[] + uploadSession UploadSession? @@index([userId]) @@index([objectKey]) @@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 { id String @id @default(cuid()) userId String diff --git a/rag-worker/api_client.py b/rag-worker/api_client.py index 23ac97f..d3758e5 100644 --- a/rag-worker/api_client.py +++ b/rag-worker/api_client.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import httpx from config import API_BASE_URL, RAG_WORKER_SECRET, WORKER_ID diff --git a/rag-worker/chunker.py b/rag-worker/chunker.py index 4f1523b..bae95bc 100644 --- a/rag-worker/chunker.py +++ b/rag-worker/chunker.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """文本切片:递归字符分割 + 中文分句保护""" import re diff --git a/src/infrastructure/storage/storage.service.ts b/src/infrastructure/storage/storage.service.ts index 3c042f0..5cb1a8b 100644 --- a/src/infrastructure/storage/storage.service.ts +++ b/src/infrastructure/storage/storage.service.ts @@ -10,6 +10,7 @@ export interface CreateUploadUrlInput { filename: string; mimeType: string; sizeBytes: number; + objectKey?: string; } export interface UploadUrlResult { @@ -41,11 +42,13 @@ export class StorageService { return `${basePath}/${filename}`; } - generateObjectKey(userId: string, originalFilename: string): string { + generateObjectKey(userId: string, originalFilename: string, uploadId?: string): string { const date = new Date(); const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`; const safeName = sanitizeFilename(originalFilename); - return `${userId}/${yearMonth}/${safeName}`; + return uploadId + ? `${userId}/${yearMonth}/${uploadId}-${safeName}` + : `${userId}/${yearMonth}/${safeName}`; } async createUploadUrl( @@ -55,9 +58,15 @@ export class StorageService { ): Promise { validateFileUpload(input.mimeType, input.sizeBytes); - const objectKey = this.generateObjectKey(userId, input.filename); - const result = await this.cos.generateUploadUrl(objectKey, expiresIn); + const objectKey = input.objectKey ?? this.generateObjectKey(userId, input.filename); + return this.createUploadUrlForObject(objectKey, expiresIn); + } + async createUploadUrlForObject( + objectKey: string, + expiresIn = 3600, + ): Promise { + const result = await this.cos.generateUploadUrl(objectKey, expiresIn); return { uploadUrl: result.uploadUrl, objectKey: result.objectKey, diff --git a/src/modules/ai-job/active-recall-projector.spec.ts b/src/modules/ai-job/active-recall-projector.spec.ts index b6d9261..64c7bb1 100644 --- a/src/modules/ai-job/active-recall-projector.spec.ts +++ b/src/modules/ai-job/active-recall-projector.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import type { Prisma } from '@prisma/client'; import { ActiveRecallProjector } from './active-recall-projector'; import { ProjectionContext } from './result-projector.interface'; @@ -43,6 +44,7 @@ function makeContext(overrides?: Partial): ProjectionC } function createMockTx() { + type ArtifactRef = { artifactType: string; artifactId: string; ordinal: number }; const store: Record = { aiAnalysisResult: [], focusItem: [], @@ -81,7 +83,7 @@ function createMockTx() { }), }, aiJobArtifact: { - findMany: jest.fn(async () => []), + findMany: jest.fn, [unknown]>(async () => []), create: jest.fn(async (args: any) => { const record = { ...args.data }; store.aiJobArtifact.push(record); @@ -91,6 +93,8 @@ function createMockTx() { }; } +type PrismaTx = Prisma.TransactionClient; + // ═══════════════════════════════════════════════════════════════════════════ // Tests // ═══════════════════════════════════════════════════════════════════════════ @@ -115,7 +119,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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 expect(artifacts.length).toBe(3); @@ -136,7 +140,7 @@ describe('ActiveRecallProjector', () => { tx.aiAnalysisResult.upsert.mockRejectedValue(new Error('DB error')); 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); @@ -148,7 +152,7 @@ describe('ActiveRecallProjector', () => { tx.focusItem.create.mockRejectedValue(new Error('FocusItem insert failed')); 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 回滚 // (实际 Prisma tx 会回滚,这里验证异常传播) @@ -159,7 +163,7 @@ describe('ActiveRecallProjector', () => { const ctx = makeContext(); 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(artifacts.filter((a) => a.artifactType === 'FocusItem').length).toBe(0); @@ -170,7 +174,7 @@ describe('ActiveRecallProjector', () => { const ctx = makeContext(); 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(artifacts.filter((a) => a.artifactType === 'ReviewCard').length).toBe(0); @@ -183,16 +187,16 @@ describe('ActiveRecallProjector', () => { 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); // 第二次执行(同一 jobId) // 模拟已有 Artifact(SyntheticResultProjector 模式) - tx.aiJobArtifact.findMany.mockResolvedValue( - tx.store.aiJobArtifact.map((a: any) => ({ ...a })), + tx.aiJobArtifact.findMany.mockImplementation(async () => + 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); // 返回已有引用,不重复创建 // 未调用 create/upsert(直接返回已有 artifacts) }); @@ -203,10 +207,10 @@ describe('ActiveRecallProjector', () => { const existingArtifacts = [ { artifactType: 'AiAnalysisResult', artifactId: 'ar_existing', ordinal: 0 }, ]; - tx.aiJobArtifact.findMany.mockResolvedValue(existingArtifacts); + tx.aiJobArtifact.findMany.mockImplementation(async () => existingArtifacts); 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[0].artifactId).toBe('ar_existing'); @@ -218,13 +222,13 @@ describe('ActiveRecallProjector', () => { 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; // 第二次(重置 mock 返回空 artifact → 触发重新投影) - tx.aiJobArtifact.findMany.mockResolvedValue([]); + tx.aiJobArtifact.findMany.mockImplementation(async () => []); // 但 upsert 找到已有记录 → update - await projector.project(tx as any, ctx); + await projector.project(tx as unknown as PrismaTx, ctx); // 仍只有 1 条 AiAnalysisResult(upsert,非重复插入) expect(tx.store.aiAnalysisResult.length).toBe(1); @@ -236,11 +240,11 @@ describe('ActiveRecallProjector', () => { const ctx = makeContext(); // 第一次写入 artifact - await projector.project(tx as any, ctx); + await projector.project(tx as unknown as PrismaTx, ctx); expect(tx.aiJobArtifact.create).toHaveBeenCalled(); // 第二次 — P2002 冲突 → 幂等跳过 - tx.aiJobArtifact.findMany.mockResolvedValue([]); + tx.aiJobArtifact.findMany.mockImplementation(async () => []); tx.aiJobArtifact.create.mockRejectedValue({ code: 'P2002' }); // 不应抛出(P2002 被 catch) @@ -254,7 +258,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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) { expect(a.artifactType).toBeTruthy(); @@ -267,7 +271,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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'); expect(arArtifact).toBeDefined(); @@ -278,7 +282,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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'); expect(fiArtifacts.length).toBe(1); @@ -294,7 +298,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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]; expect(result.userId).toBe('u-001'); @@ -312,7 +316,7 @@ describe('ActiveRecallProjector', () => { const ctx = makeContext(); 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(); }); }); @@ -322,7 +326,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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]; expect(fi.title).toBe('X的应用条件'); @@ -337,7 +341,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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]; expect(card.userId).toBe('u-001'); @@ -353,7 +357,7 @@ describe('ActiveRecallProjector', () => { const ctx = makeContext(); 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]; expect(card.intervalDays).toBe(365); }); @@ -364,7 +368,7 @@ describe('ActiveRecallProjector', () => { const tx = createMockTx(); 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 uniqueOrdinals = [...new Set(ordinals)]; diff --git a/src/modules/ai-job/active-recall-registration.service.spec.ts b/src/modules/ai-job/active-recall-registration.service.spec.ts index 7ebb2e4..d1891f5 100644 --- a/src/modules/ai-job/active-recall-registration.service.spec.ts +++ b/src/modules/ai-job/active-recall-registration.service.spec.ts @@ -1,12 +1,10 @@ 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 { ActiveRecallRegistrationService } from './active-recall-registration.service'; import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; import { PrismaService } from '../../infrastructure/database/prisma.service'; -import { JobDefinitionRegistry } from './job-definition-registry'; -import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; // ═══════════════════════════════════════════════════════════════════════════ // ActiveRecallRegistrationService diff --git a/src/modules/ai-job/feynman-projector.spec.ts b/src/modules/ai-job/feynman-projector.spec.ts index a40f23a..9238f2f 100644 --- a/src/modules/ai-job/feynman-projector.spec.ts +++ b/src/modules/ai-job/feynman-projector.spec.ts @@ -34,7 +34,7 @@ function makeProject() { a.artifactId === data.artifactId, ); if (exists) { - const err = new Error('Unique constraint violation') as any; + const err: Error & { code?: string } = new Error('Unique constraint violation'); err.code = 'P2002'; throw err; } @@ -699,7 +699,8 @@ describe('FeynmanProjector', () => { await projector.project(tx, ctx); // 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', ); expect(childArtifactCreates.length).toBe(1); diff --git a/src/modules/ai-job/learning-analysis-snapshot-builder.ts b/src/modules/ai-job/learning-analysis-snapshot-builder.ts index d426698..8a220af 100644 --- a/src/modules/ai-job/learning-analysis-snapshot-builder.ts +++ b/src/modules/ai-job/learning-analysis-snapshot-builder.ts @@ -79,6 +79,7 @@ export interface LearningAnalysisSnapshot { coverageStart?: string; coverageEnd?: string; insufficientDataReasons: string[]; + overall: 'insufficient' | 'limited' | 'sufficient'; }; promptKey: string; promptVersion: string; diff --git a/src/modules/ai-runtime/user-ai.controller.spec.ts b/src/modules/ai-runtime/user-ai.controller.spec.ts index 483c665..4886cc2 100644 --- a/src/modules/ai-runtime/user-ai.controller.spec.ts +++ b/src/modules/ai-runtime/user-ai.controller.spec.ts @@ -1,9 +1,20 @@ 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', () => { let controller: UserAiController; let mockService: any; let mockQuizRouter: any; + let mockLearningAnalysisRouter: LearningAnalysisExecutionRouter; + let mockQuotaGuard: QuotaGuardService; beforeEach(() => { mockService = { @@ -39,14 +50,27 @@ describe('UserAiController', () => { mockQuizRouter = { generateQuiz: jest.fn(), }; - const mockLearningAnalysisRouter = { - analyze: jest.fn(), - }; - 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) }; + mockLearningAnalysisRouter = new LearningAnalysisExecutionRouter( + { isEnabled: jest.fn() } as never, + { build: jest.fn() } as never, + { 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); }); - const req = (id = 'u1') => ({ user: { id } }) as any; + const req = (id = 'u1') => ({ user: { id } }); // ═══════════════════════════════════════════════════════════════════ // Profile @@ -67,9 +91,9 @@ describe('UserAiController', () => { }); it('PUT profile delegates to service', async () => { - const dto = { learningGoal: 'exam' }; + const dto: SaveLearningProfileDto = { learningGoal: 'exam' }; 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(result).toEqual({ id: 'p1' }); }); @@ -88,9 +112,9 @@ describe('UserAiController', () => { }); it('PUT settings', async () => { - const dto = { allowAiAnalysis: false }; + const dto: UpdateAiSettingsDto = { allowAiAnalysis: false }; 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(result).toEqual({ id: 's1' }); }); @@ -109,17 +133,17 @@ describe('UserAiController', () => { }); 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' }); - const result = await controller.createCredential(req(), dto as any); + const result = await controller.createCredential(req(), dto); expect(mockService.createCredential).toHaveBeenCalledWith('u1', dto); expect(result).toEqual({ id: 'c1' }); }); 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' }); - 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(result).toEqual({ id: 'c1' }); }); @@ -145,18 +169,18 @@ describe('UserAiController', () => { describe('Analysis Jobs', () => { 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' }); - const result = await controller.createAnalysisJob(req(), dto as any); + const result = await controller.createAnalysisJob(req(), dto); expect(mockQuizRouter.generateQuiz).toHaveBeenCalledWith('u1', dto); expect(mockService.createAnalysisJob).not.toHaveBeenCalled(); expect(result).toEqual({ jobId: 'j1', status: 'queued' }); }); 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' }); - const result = await controller.createAnalysisJob(req(), dto as any); + const result = await controller.createAnalysisJob(req(), dto); expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto); expect(mockQuizRouter.generateQuiz).not.toHaveBeenCalled(); expect(result).toEqual({ jobId: 'j2' }); diff --git a/src/modules/document-import/document-import.controller.ts b/src/modules/document-import/document-import.controller.ts index 992a9b9..a62ed52 100644 --- a/src/modules/document-import/document-import.controller.ts +++ b/src/modules/document-import/document-import.controller.ts @@ -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 { DocumentImportService } from './document-import.service'; import { CreateImportDto } from './dto/create-import.dto'; -import { QuotaGuardService } from '../membership/quota-guard.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { UserPayload } from '../../common/types'; @ApiTags('document-import') @Controller('imports') export class DocumentImportController { - constructor( - private readonly service: DocumentImportService, - private readonly quotaGuard: QuotaGuardService, - ) {} + constructor(private readonly service: DocumentImportService) {} @Post() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '创建导入任务' }) async createImport(@CurrentUser() user: UserPayload, @Body() dto: CreateImportDto) { - const 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 }); + return this.service.createImport({ ...dto, userId: user.id }); } @Get(':id/status') diff --git a/src/modules/document-import/document-import.module.ts b/src/modules/document-import/document-import.module.ts index d2d1bbf..d9b48e7 100644 --- a/src/modules/document-import/document-import.module.ts +++ b/src/modules/document-import/document-import.module.ts @@ -4,10 +4,8 @@ import { AdminImportsController } from './admin-imports.controller'; import { DocumentImportService } from './document-import.service'; import { DocumentImportRepository } from './document-import.repository'; import { PrismaService } from '../../infrastructure/database/prisma.service'; -import { MembershipModule } from '../membership/membership.module'; @Module({ - imports: [MembershipModule], controllers: [DocumentImportController, AdminImportsController], providers: [DocumentImportService, DocumentImportRepository, PrismaService], exports: [DocumentImportService, DocumentImportRepository], diff --git a/src/modules/files/admin-files.controller.ts b/src/modules/files/admin-files.controller.ts index 03ca951..d1c91ff 100644 --- a/src/modules/files/admin-files.controller.ts +++ b/src/modules/files/admin-files.controller.ts @@ -2,18 +2,18 @@ import { Controller, Get, Delete, Param, Query, UseGuards } from '@nestjs/common import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { PrismaService } from '../../infrastructure/database/prisma.service'; 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 { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; import type { AdminRole } from '../../common/types/admin-role.enum'; +import { FilesService } from './files.service'; @ApiTags('admin-files') @Controller('admin-api/files') @UseGuards(AdminAuthGuard, AdminRolesGuard) @ApiBearerAuth() export class AdminFilesController { - constructor(private readonly prisma: PrismaService, private readonly queue: QueueService) {} + constructor(private readonly prisma: PrismaService, private readonly filesService: FilesService) {} @Get() @AdminRoles('SUPER_ADMIN' as AdminRole) @@ -36,11 +36,6 @@ export class AdminFilesController { @AdminRoles('SUPER_ADMIN' as AdminRole) @ApiOperation({ summary: '软删除文件' }) async remove(@Param('id') id: string) { - const file = await this.prisma.uploadedFile.findUnique({ where: { 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 }; + return this.filesService.deleteFileAsAdmin(id); } } diff --git a/src/modules/files/dto/complete-upload.dto.ts b/src/modules/files/dto/complete-upload.dto.ts index 22c5a88..b4969cb 100644 --- a/src/modules/files/dto/complete-upload.dto.ts +++ b/src/modules/files/dto/complete-upload.dto.ts @@ -2,9 +2,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsOptional } from 'class-validator'; export class CompleteUploadDto { - @ApiProperty({ description: 'COS 对象键(上传 URL 响应中返回)' }) + @ApiProperty({ description: '上传会话 ID(upload-url 响应中返回)' }) @IsString() - objectKey: string; + uploadId: string; + + @ApiPropertyOptional({ description: 'COS 对象键(用于客户端幂等校验)' }) + @IsOptional() + @IsString() + objectKey?: string; @ApiPropertyOptional({ description: '文件 SHA256 校验和' }) @IsOptional() diff --git a/src/modules/files/files.controller.ts b/src/modules/files/files.controller.ts index 0ee4ec2..09990f7 100644 --- a/src/modules/files/files.controller.ts +++ b/src/modules/files/files.controller.ts @@ -1,41 +1,22 @@ -import { Controller, Get, Post, Delete, Body, Param, BadRequestException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FilesService } from './files.service'; import { CreateUploadUrlDto, CompleteUploadDto } from './dto'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator'; -import { QuotaGuardService } from '../membership/quota-guard.service'; import type { UserPayload } from '../../common/types'; @ApiTags('files') @Controller('files') @ApiBearerAuth() export class FilesController { - constructor( - private readonly filesService: FilesService, - private readonly quotaGuard: QuotaGuardService, - ) {} + constructor(private readonly filesService: FilesService) {} @Post('upload-url') @FileUploadRateLimit() @ApiOperation({ summary: '获取预签名上传 URL' }) async createUploadUrl(@CurrentUser() user: UserPayload, @Body() dto: CreateUploadUrlDto) { - const userId = user.id; - 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'); } + return this.filesService.requestUploadUrl(user.id, dto); } @Post('complete') @@ -55,10 +36,6 @@ export class FilesController { @Delete(':id') @ApiOperation({ summary: '删除文件(COS + 数据库)' }) async deleteFile(@CurrentUser() user: UserPayload, @Param('id') id: string) { - const file = await this.filesService.getFile(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; + return this.filesService.deleteFile(user.id, id); } } diff --git a/src/modules/files/files.repository.ts b/src/modules/files/files.repository.ts index 2180580..3098e52 100644 --- a/src/modules/files/files.repository.ts +++ b/src/modules/files/files.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { Prisma } from '@prisma/client'; @Injectable() export class FilesRepository { @@ -23,7 +24,7 @@ export class FilesRepository { } async findByObjectKey(objectKey: string) { - return this.prisma.uploadedFile.findFirst({ where: { objectKey } }); + return this.prisma.uploadedFile.findUnique({ where: { objectKey } }); } async findByUserId(userId: string) { @@ -36,4 +37,80 @@ export class FilesRepository { async delete(id: string) { 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); + } } diff --git a/src/modules/files/files.service.spec.ts b/src/modules/files/files.service.spec.ts new file mode 100644 index 0000000..a65a51d --- /dev/null +++ b/src/modules/files/files.service.spec.ts @@ -0,0 +1,494 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { FilesService } from './files.service'; + +describe('FilesService', () => { + type FilesServiceDeps = ConstructorParameters; + + 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 }) => { + 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(); + }); +}); diff --git a/src/modules/files/files.service.ts b/src/modules/files/files.service.ts index 941fb48..11e2cee 100644 --- a/src/modules/files/files.service.ts +++ b/src/modules/files/files.service.ts @@ -1,13 +1,22 @@ import { + BadRequestException, Injectable, NotFoundException, ForbiddenException, } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; import { FilesRepository } from './files.repository'; import { StorageService } from '../../infrastructure/storage/storage.service'; import { CosStorageProvider } from '../../infrastructure/storage/cos-storage.provider'; import { ContentSafetyService } from '../content-safety/content-safety.service'; 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() export class FilesService { @@ -16,6 +25,9 @@ export class FilesService { private readonly storage: StorageService, private readonly cos: CosStorageProvider, private readonly safety: ContentSafetyService, + private readonly quotaService: EffectiveQuotaService, + private readonly membershipService: EffectiveMembershipService, + private readonly prisma: PrismaService, ) {} async requestUploadUrl(userId: string, dto: CreateUploadUrlDto) { @@ -23,29 +35,175 @@ export class FilesService { if (!check.safe) { throw new ForbiddenException('文件名包含违规内容'); } - return this.storage.createUploadUrl(userId, { - filename: dto.filename, - mimeType: dto.mimeType, - sizeBytes: dto.sizeBytes, + + 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, + mimeType: dto.mimeType, + 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) { - const info = await this.storage.verifyUpload(dto.objectKey); + await this.cleanupExpiredUploadSessions(userId); - const parts = dto.objectKey.split('/'); - const originalFilename = parts[parts.length - 1]; + const session = await this.repository.findUploadSessionById(dto.uploadId); + 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({ - userId, - filename: originalFilename, - mimeType: info.contentType, - storagePath: dto.objectKey, - objectKey: dto.objectKey, - bucket: this.cos.getBucket(), - sizeBytes: info.size, - checksum: dto.checksum, - }); + 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, + filename: this.extractOriginalFilename(current.objectKey), + mimeType: info.contentType, + storagePath: current.objectKey, + objectKey: current.objectKey, + bucket: this.cos.getBucket(), + sizeBytes: info.size, + 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) { @@ -72,9 +230,145 @@ export class FilesService { await this.storage.deleteObject(file.objectKey!); 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) { 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; + } } diff --git a/src/modules/knowledge-base/knowledge-base.module.ts b/src/modules/knowledge-base/knowledge-base.module.ts index f395d6e..fbdfa06 100644 --- a/src/modules/knowledge-base/knowledge-base.module.ts +++ b/src/modules/knowledge-base/knowledge-base.module.ts @@ -5,11 +5,13 @@ import { KnowledgeBaseRepository } from './knowledge-base.repository'; import { SystemKnowledgeBaseSeed } from './system-kb.seed'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { MembershipModule } from '../membership/membership.module'; +import { FilesModule } from '../files/files.module'; +import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository'; @Module({ - imports: [MembershipModule], + imports: [MembershipModule, FilesModule], controllers: [KnowledgeBaseController], - providers: [KnowledgeBaseService, KnowledgeBaseRepository, SystemKnowledgeBaseSeed, PrismaService], + providers: [KnowledgeBaseService, KnowledgeBaseRepository, KnowledgeSourceRepository, SystemKnowledgeBaseSeed, PrismaService], exports: [KnowledgeBaseService], }) export class KnowledgeBaseModule {} diff --git a/src/modules/knowledge-base/knowledge-base.service.spec.ts b/src/modules/knowledge-base/knowledge-base.service.spec.ts new file mode 100644 index 0000000..b304503 --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.service.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts index 2fd4927..b281276 100644 --- a/src/modules/knowledge-base/knowledge-base.service.ts +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -4,6 +4,8 @@ import { PrismaService } from '../../infrastructure/database/prisma.service'; import { ContentSafetyService } from '../content-safety/content-safety.service'; import { StorageService } from '../../infrastructure/storage/storage.service'; 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() export class KnowledgeBaseService { @@ -11,6 +13,8 @@ export class KnowledgeBaseService { private readonly repository: KnowledgeBaseRepository, private readonly prisma: PrismaService, private readonly storage: StorageService, + private readonly sourceRepository: KnowledgeSourceRepository, + private readonly filesService: FilesService, @Optional() private readonly safety?: ContentSafetyService, ) {} @@ -71,6 +75,15 @@ export class KnowledgeBaseService { if (!kb || String(kb.userId) !== userId) { 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); } diff --git a/src/modules/knowledge-source/knowledge-source.controller.ts b/src/modules/knowledge-source/knowledge-source.controller.ts index a57c78b..8598923 100644 --- a/src/modules/knowledge-source/knowledge-source.controller.ts +++ b/src/modules/knowledge-source/knowledge-source.controller.ts @@ -44,6 +44,6 @@ export class KnowledgeSourceController { @Delete(':id') @ApiOperation({ summary: '删除资料来源' }) async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { - return this.service.remove(id); + return this.service.remove(user.id, id); } } diff --git a/src/modules/knowledge-source/knowledge-source.module.ts b/src/modules/knowledge-source/knowledge-source.module.ts index e23cead..0052df3 100644 --- a/src/modules/knowledge-source/knowledge-source.module.ts +++ b/src/modules/knowledge-source/knowledge-source.module.ts @@ -3,9 +3,10 @@ import { KnowledgeSourceController } from './knowledge-source.controller'; import { KnowledgeSourceService } from './knowledge-source.service'; import { KnowledgeSourceRepository } from './knowledge-source.repository'; import { DocumentImportModule } from '../document-import/document-import.module'; +import { FilesModule } from '../files/files.module'; @Module({ - imports: [DocumentImportModule], + imports: [DocumentImportModule, FilesModule], controllers: [KnowledgeSourceController], providers: [KnowledgeSourceService, KnowledgeSourceRepository], exports: [KnowledgeSourceService, KnowledgeSourceRepository], diff --git a/src/modules/knowledge-source/knowledge-source.repository.ts b/src/modules/knowledge-source/knowledge-source.repository.ts index 5aa2389..228405c 100644 --- a/src/modules/knowledge-source/knowledge-source.repository.ts +++ b/src/modules/knowledge-source/knowledge-source.repository.ts @@ -47,6 +47,21 @@ export class KnowledgeSourceRepository { 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) { return this.prisma.knowledgeSource.update({ 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 }) { return this.prisma.knowledgeSource.update({ where: { id }, diff --git a/src/modules/knowledge-source/knowledge-source.service.spec.ts b/src/modules/knowledge-source/knowledge-source.service.spec.ts new file mode 100644 index 0000000..186d288 --- /dev/null +++ b/src/modules/knowledge-source/knowledge-source.service.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/knowledge-source/knowledge-source.service.ts b/src/modules/knowledge-source/knowledge-source.service.ts index a097a62..ccf0f85 100644 --- a/src/modules/knowledge-source/knowledge-source.service.ts +++ b/src/modules/knowledge-source/knowledge-source.service.ts @@ -3,6 +3,7 @@ import { KnowledgeSourceRepository } from './knowledge-source.repository'; import { DocumentImportRepository } from '../document-import/document-import.repository'; import { QueueService } from '../../infrastructure/queue/queue.service'; import { RedisService } from '../../infrastructure/redis/redis.service'; +import { FilesService } from '../files/files.service'; @Injectable() export class KnowledgeSourceService { @@ -11,6 +12,7 @@ export class KnowledgeSourceService { private readonly importRepo: DocumentImportRepository, private readonly queue: QueueService, private readonly redis: RedisService, + private readonly filesService: FilesService, ) {} async addSource(userId: string, knowledgeBaseId: string, dto: { @@ -62,9 +64,12 @@ export class KnowledgeSourceService { return source; } - async remove(id: string) { + async remove(userId: string, id: string) { const source = await this.repository.findById(id); if (!source) throw new NotFoundException('资料来源不存在'); + if (source.fileId) { + await this.filesService.deleteFile(userId, source.fileId); + } return this.repository.softDelete(id); } diff --git a/src/modules/membership/membership.controller.spec.ts b/src/modules/membership/membership.controller.spec.ts index f8981bb..54a0323 100644 --- a/src/modules/membership/membership.controller.spec.ts +++ b/src/modules/membership/membership.controller.spec.ts @@ -24,8 +24,8 @@ describe('MembershipController', () => { 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 } }); const r = await ctrl.getMyMembership({ user:{ id:'u1' } }); - expect(r.transactionId).toBeUndefined(); - expect(r.originalTransactionId).toBeUndefined(); + expect(r).not.toHaveProperty('transactionId'); + expect(r).not.toHaveProperty('originalTransactionId'); }); it('GET /membership/plans returns active plans', async () => { diff --git a/src/modules/membership/quota-guard.service.spec.ts b/src/modules/membership/quota-guard.service.spec.ts index be99768..ccba9e1 100644 --- a/src/modules/membership/quota-guard.service.spec.ts +++ b/src/modules/membership/quota-guard.service.spec.ts @@ -1,5 +1,5 @@ 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 { PrismaService } from '../../infrastructure/database/prisma.service'; @@ -7,8 +7,8 @@ describe('QuotaGuardService', () => { let guard: QuotaGuardService; let mockQuota: any; - const allowed = { 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 allowed: QuotaCheckResult = { allowed:true, quotaType:'quiz_generation', limit:30, used:5, remaining:25, resetAt: null, upgradeAvailable:false }; + const denied: QuotaCheckResult = { allowed:false, quotaType:'quiz_generation', limit:30, used:31, remaining:0, resetAt: null, upgradeAvailable:true }; beforeEach(async () => { mockQuota = { @@ -31,6 +31,7 @@ describe('QuotaGuardService', () => { const r = await guard.check('u1', 'quiz_generation', 'op1'); expect(r.allowed).toBe(false); expect(r.upgradeAvailable).toBe(true); + expect(typeof r.resetAt).toBe('string'); }); it('buildError returns QUOTA_EXCEEDED for ai types', () => { diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts new file mode 100644 index 0000000..5d0e19a --- /dev/null +++ b/src/modules/users/users.service.spec.ts @@ -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); + }); +}); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 99f936a..27bf9c2 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -87,19 +87,38 @@ export class UsersService { // ── Storage ── async getStorage(userId: string) { - const files = await this.prisma.uploadedFile.findMany({ - where: { userId }, - select: { sizeBytes: true, mimeType: true }, - }); - const usedBytes = files.reduce((sum, f) => sum + Number(f.sizeBytes), 0); - // M-MEMBER-01-CLOSE-01E: 从 EffectiveQuota 读取真实存储配额,不再硬编码 1GB - let totalBytes = 1073741824; // fallback(quota 不可用时) + const now = new Date(); + const [files, pendingReservations] = await Promise.all([ + this.prisma.uploadedFile.findMany({ + where: { userId }, + select: { sizeBytes: true, mimeType: true }, + }), + this.prisma.uploadSession.findMany({ + where: { + 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) { try { const q = await this.quotaService.computeEffectiveQuota(userId); const storageLimit = q.limits.find(l => l.quotaType === 'storage_bytes'); 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 }; } diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 4ff6fad..0bbab9d 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,8 +1,9 @@ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "..", + "roots": ["/test"], "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": "test/.*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }] }, diff --git a/test/jest-integration.json b/test/jest-integration.json new file mode 100644 index 0000000..8106c9e --- /dev/null +++ b/test/jest-integration.json @@ -0,0 +1,12 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "roots": ["/test", "/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 +} diff --git a/test/jest-unit.json b/test/jest-unit.json new file mode 100644 index 0000000..5c3d02e --- /dev/null +++ b/test/jest-unit.json @@ -0,0 +1,20 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "roots": ["/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" +} diff --git a/src/modules/ai-job/m-ai-03-synthetic.integration.spec.ts b/test/m-ai-03-synthetic.integration-spec.ts similarity index 100% rename from src/modules/ai-job/m-ai-03-synthetic.integration.spec.ts rename to test/m-ai-03-synthetic.integration-spec.ts diff --git a/test/run-integration-tests.sh b/test/run-integration-tests.sh index 445d893..f1eab86 100644 --- a/test/run-integration-tests.sh +++ b/test/run-integration-tests.sh @@ -1,54 +1,42 @@ #!/bin/bash -# M-AI-01-09 真实集成测试 CI Runner -# 启动真实 MySQL/Redis/API/Worker/Mock AI Provider 并执行集成测试 +# Integration test runner +# Runs only integration suites under test/: +# - *.integration-spec.ts +# - *.worker-int-spec.ts +# Requires real MySQL, Redis and BullMQ worker dependencies. 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}" REDIS_H="${REDIS_HOST:-127.0.0.1}" REDIS_P="${REDIS_PORT:-6379}" REDIS_PW="${REDIS_PASSWORD:-}" -# 1. Check MySQL (via docker exec since mysqladmin may not be in CI PATH) -echo "[1/7] Checking MySQL..." -docker exec mysql mysqladmin ping -u root -p"${MYSQL_ROOT_PASSWORD:-root}" --silent 2>/dev/null || { - echo "MySQL not reachable via docker; ensure MySQL container is running" - 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 ===" +echo "[1/4] Checking MySQL..." +if command -v mysqladmin >/dev/null 2>&1; then + 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 else - echo "=== M-AI-01-09: FAIL ===" + 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 + } 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