feat: 本地开发环境配置 + Python 3.9 兼容修复
- 添加 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:
parent
cfa6c6bc9c
commit
05cf369bee
51
docker-compose.local.yml
Normal file
51
docker-compose.local.yml
Normal file
@ -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:
|
||||
14
mysql.conf.d/zhixi.cnf
Normal file
14
mysql.conf.d/zhixi.cnf
Normal 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
|
||||
18
package.json
18
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": [
|
||||
"<rootDir>/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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from config import API_BASE_URL, RAG_WORKER_SECRET, WORKER_ID
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""文本切片:递归字符分割 + 中文分句保护"""
|
||||
|
||||
import re
|
||||
|
||||
@ -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<UploadUrlResult> {
|
||||
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<UploadUrlResult> {
|
||||
const result = await this.cos.generateUploadUrl(objectKey, expiresIn);
|
||||
return {
|
||||
uploadUrl: result.uploadUrl,
|
||||
objectKey: result.objectKey,
|
||||
|
||||
@ -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<ProjectionContext['job']>): ProjectionC
|
||||
}
|
||||
|
||||
function createMockTx() {
|
||||
type ArtifactRef = { artifactType: string; artifactId: string; ordinal: number };
|
||||
const store: Record<string, any[]> = {
|
||||
aiAnalysisResult: [],
|
||||
focusItem: [],
|
||||
@ -81,7 +83,7 @@ function createMockTx() {
|
||||
}),
|
||||
},
|
||||
aiJobArtifact: {
|
||||
findMany: jest.fn(async () => []),
|
||||
findMany: jest.fn<Promise<ArtifactRef[]>, [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)];
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -79,6 +79,7 @@ export interface LearningAnalysisSnapshot {
|
||||
coverageStart?: string;
|
||||
coverageEnd?: string;
|
||||
insufficientDataReasons: string[];
|
||||
overall: 'insufficient' | 'limited' | 'sufficient';
|
||||
};
|
||||
promptKey: string;
|
||||
promptVersion: string;
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
494
src/modules/files/files.service.spec.ts
Normal file
494
src/modules/files/files.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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, {
|
||||
|
||||
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({
|
||||
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: originalFilename,
|
||||
filename: this.extractOriginalFilename(current.objectKey),
|
||||
mimeType: info.contentType,
|
||||
storagePath: dto.objectKey,
|
||||
objectKey: dto.objectKey,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
99
src/modules/knowledge-base/knowledge-base.service.spec.ts
Normal file
99
src/modules/knowledge-base/knowledge-base.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
106
src/modules/users/users.service.spec.ts
Normal file
106
src/modules/users/users.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -87,19 +87,38 @@ export class UsersService {
|
||||
// ── Storage ──
|
||||
|
||||
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 },
|
||||
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 不可用时)
|
||||
}),
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "..",
|
||||
"roots": ["<rootDir>/test"],
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }]
|
||||
},
|
||||
|
||||
12
test/jest-integration.json
Normal file
12
test/jest-integration.json
Normal 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
20
test/jest-unit.json
Normal 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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user