diff --git a/prisma/migrations/20260525030000_add_import_step_log/migration.sql b/prisma/migrations/20260525030000_add_import_step_log/migration.sql new file mode 100644 index 0000000..9c3c191 --- /dev/null +++ b/prisma/migrations/20260525030000_add_import_step_log/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `ImportStepLog` ( + `id` VARCHAR(191) NOT NULL, + `importId` VARCHAR(191) NOT NULL, + `step` VARCHAR(32) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `detail` VARCHAR(500) NULL, + `startedAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `ImportStepLog_importId_idx`(`importId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cfe3d2..f3f5e61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -317,6 +317,19 @@ model DocumentImport { @@index([workerId]) } +model ImportStepLog { + id String @id @default(cuid()) + importId String + step String @db.VarChar(32) + status String @db.VarChar(16) + detail String? @db.VarChar(500) + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + + @@index([importId]) +} + model LearningSession { id String @id @default(cuid()) userId String diff --git a/src/modules/document-import/admin-imports.controller.ts b/src/modules/document-import/admin-imports.controller.ts new file mode 100644 index 0000000..db5defa --- /dev/null +++ b/src/modules/document-import/admin-imports.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { PrismaService } from '../../infrastructure/database/prisma.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'; + +@ApiTags('admin-imports') +@Controller('admin-api/imports') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@ApiBearerAuth() +export class AdminImportsController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @AdminRoles('ADMIN' as AdminRole) + @ApiOperation({ summary: '导入任务列表' }) + async list(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) { + const p = parseInt(page), l = parseInt(limit); + const where: any = status ? { status } : {}; + const [items, total] = await Promise.all([ + this.prisma.documentImport.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }), + this.prisma.documentImport.count({ where }), + ]); + return { items, total, page: p, limit: l, totalPages: Math.ceil(total / l) }; + } + + @Get(':id') + @AdminRoles('ADMIN' as AdminRole) + @ApiOperation({ summary: '导入任务详情' }) + async detail(@Param('id') id: string) { + const [job, steps] = await Promise.all([ + this.prisma.documentImport.findUnique({ where: { id } }), + this.prisma.importStepLog.findMany({ where: { importId: id }, orderBy: { createdAt: 'asc' } }), + ]); + return { job, steps }; + } + + @Post(':id/retry') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '重试失败导入' }) + async retry(@Param('id') id: string) { + await this.prisma.documentImport.update({ + where: { id }, + data: { status: 'QUEUED', retryCount: 0, errorMessage: null }, + }); + return { success: true }; + } +} diff --git a/src/modules/document-import/document-import.module.ts b/src/modules/document-import/document-import.module.ts index fe458a9..d9b48e7 100644 --- a/src/modules/document-import/document-import.module.ts +++ b/src/modules/document-import/document-import.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { DocumentImportController } from './document-import.controller'; +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'; @Module({ - controllers: [DocumentImportController], - providers: [DocumentImportService, DocumentImportRepository], + controllers: [DocumentImportController, AdminImportsController], + providers: [DocumentImportService, DocumentImportRepository, PrismaService], exports: [DocumentImportService, DocumentImportRepository], }) export class DocumentImportModule {} diff --git a/test/m2.e2e-spec.ts b/test/m2.e2e-spec.ts index 9176e42..269cdc3 100644 --- a/test/m2.e2e-spec.ts +++ b/test/m2.e2e-spec.ts @@ -185,4 +185,52 @@ describe('M2 E2E Tests', () => { expect(Array.isArray(res.body.data)).toBe(true); }); }); + + // ══════════════════════════════════════════════ + // M2-04: Ingestion & Indexing + // ══════════════════════════════════════════════ + describe('M2-04 Ingestion & Indexing', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('POST /api/imports → 201 create import', async () => { + const res = await request(app.getHttpServer()) + .post('/api/imports') + .send({ sourceType: 'file', sourceName: 'test.pdf', knowledgeBaseId: 'kb1', userId: 'user1' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + it('GET /admin-api/imports → 200 import list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/imports') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('items'); + expect(res.body.data).toHaveProperty('total'); + }); + + it('GET /admin-api/imports → 401 without token', async () => { + await request(app.getHttpServer()).get('/admin-api/imports').expect(401); + }); + + it('GET /admin-api/imports/:id → 200 import detail', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/imports/test-id') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('job'); + }); + + it('POST /admin-api/imports/:id/retry → retry failed import', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .post('/admin-api/imports/test-id/retry') + .set('Authorization', `Bearer ${token}`) + .expect([200, 201]); + expect(res.body.success).toBe(true); + }); + }); }); diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index 7704439..55a5f9c 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -91,7 +91,7 @@ const modelNames = [ 'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord', 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest', - 'workspace', 'knowledgeFolder', 'sourceReference', + 'workspace', 'knowledgeFolder', 'sourceReference', 'importStepLog', ] for (const name of modelNames) {