diff --git a/README.md b/README.md index 6ec508d..c8bcc0f 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ ``` 前端 (React + Vite) 后端 (NestJS) 数据库 ┌─────────────────┐ ┌──────────────────┐ ┌──────────┐ -│ React 19 │ │ NestJS 11 │ │ SQLite │ -│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ (开发) │ -│ ECharts │ │ JWT + Passport │ │ MySQL 8 │ -│ Vite 8 │ │ ExcelJS + PDFKit │ │ (生产) │ +│ React 19 │ │ NestJS 11 │ │ MySQL 8 │ +│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ │ +│ ECharts │ │ JWT + Passport │ │ │ +│ Vite 8 │ │ ExcelJS + PDFKit │ │ │ └─────────────────┘ └──────────────────┘ └──────────┘ ``` @@ -37,6 +37,7 @@ - Node.js >= 18 - npm >= 9 +- MySQL 8.0 ### 后端启动 @@ -93,7 +94,7 @@ docker-compose up -d # 一键启动 MySQL + 后端 + 前端 | 配置项 | 说明 | 默认值 | |--------|------|--------| -| `DB_TYPE` | 数据库类型 | `mysql` | +| `DB_TYPE` | 数据库类型(仅支持 MySQL) | `mysql` | | `DB_HOST` | 数据库地址 | `localhost` | | `DB_PORT` | 数据库端口 | `3306` | | `DB_USERNAME` | 数据库用户名 | `dorm_billing` | diff --git a/apps/server/datasource.ts b/apps/server/datasource.ts index 7c1ea88..3263814 100644 --- a/apps/server/datasource.ts +++ b/apps/server/datasource.ts @@ -6,16 +6,14 @@ import { join } from 'path'; const root = process.cwd(); config({ path: join(root, '.env') }); -const dbType = process.env.DB_TYPE || 'sqlite'; - export default new DataSource({ - type: dbType === 'mysql' ? 'mysql' : 'better-sqlite3', - host: dbType === 'mysql' ? (process.env.DB_HOST || 'localhost') : undefined, - port: dbType === 'mysql' ? (Number(process.env.DB_PORT) || 3306) : undefined, - username: dbType === 'mysql' ? (process.env.DB_USERNAME || 'root') : undefined, - password: dbType === 'mysql' ? (process.env.DB_PASSWORD || '') : undefined, - database: process.env.DB_DATABASE || (dbType === 'mysql' ? 'dorm_billing' : 'dorm_billing.db'), - charset: dbType === 'mysql' ? 'utf8mb4' : undefined, + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 3306, + username: process.env.DB_USERNAME || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_DATABASE || 'dorm_billing', + charset: 'utf8mb4', entities: [join(root, 'src/**/*.entity.ts')], migrations: [join(root, 'src/migrations/*.ts')], }); diff --git a/apps/server/package.json b/apps/server/package.json index 1069ea5..beea8ec 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -65,16 +65,12 @@ "rxjs": "^7.8.1", "typeorm": "^0.3.31" }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0" - }, "devDependencies": { "@eslint/js": "^9.18.0", "@gongxue/typescript-config": "*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", diff --git a/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts b/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts deleted file mode 100644 index 3c21ee2..0000000 --- a/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddAiChat1784780000000 } from '../migrations/1784780000000-AddAiChat'; -import { EnhanceAiChatForAntDesignX1784860000000 } from '../migrations/1784860000000-EnhanceAiChatForAntDesignX'; -import { DropAiMessageFeedback1784920000000 } from '../migrations/1784920000000-DropAiMessageFeedback'; - -describe('EnhanceAiChatForAntDesignX1784860000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddAiChat1784780000000, EnhanceAiChatForAntDesignX1784860000000], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - await dataSource.query( - 'CREATE TABLE ai_config (id integer PRIMARY KEY AUTOINCREMENT, singleton_key varchar(20) NOT NULL)', - ); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('adds Ant Design X chat fields and attachment relations', async () => { - await dataSource.runMigrations(); - const runner = dataSource.createQueryRunner(); - for (const table of ['ai_attachments', 'ai_message_attachments']) { - expect(await runner.hasTable(table)).toBe(true); - } - expect(await runner.hasColumn('ai_config', 'supports_vision')).toBe(true); - expect(await runner.hasColumn('ai_conversations', 'locked_skill_key')).toBe(true); - expect(await runner.hasColumn('ai_messages', 'feedback')).toBe(true); - expect(await runner.hasColumn('ai_tool_runs', 'skill_key')).toBe(true); - await runner.release(); - }); - - it('drops the removed like/dislike feedback columns', async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [ - AddAiChat1784780000000, - EnhanceAiChatForAntDesignX1784860000000, - DropAiMessageFeedback1784920000000, - ], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - await dataSource.query( - 'CREATE TABLE ai_config (id integer PRIMARY KEY AUTOINCREMENT, singleton_key varchar(20) NOT NULL)', - ); - await dataSource.runMigrations(); - const runner = dataSource.createQueryRunner(); - expect(await runner.hasColumn('ai_messages', 'feedback')).toBe(false); - expect(await runner.hasColumn('ai_messages', 'feedback_reason')).toBe(false); - await runner.release(); - }); -}); diff --git a/apps/server/src/ai-chat/ai-chat.migration.spec.ts b/apps/server/src/ai-chat/ai-chat.migration.spec.ts deleted file mode 100644 index 2087572..0000000 --- a/apps/server/src/ai-chat/ai-chat.migration.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddAiChat1784780000000 } from '../migrations/1784780000000-AddAiChat'; - -describe('AddAiChat1784780000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddAiChat1784780000000], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('创建会话、消息和工具记录表,并按会话级联删除', async () => { - await dataSource.runMigrations(); - - for (const table of ['ai_conversations', 'ai_messages', 'ai_tool_runs']) { - expect(await dataSource.createQueryRunner().hasTable(table)).toBe(true); - } - - await dataSource.query("INSERT INTO users (username) VALUES ('tester')"); - await dataSource.query( - "INSERT INTO ai_conversations (user_id, title) VALUES (1, '测试会话')", - ); - await dataSource.query( - "INSERT INTO ai_messages (conversation_id, role, content) VALUES (1, 'assistant', '回答')", - ); - await dataSource.query( - "INSERT INTO ai_tool_runs (message_id, tool_call_id, tool_name, status) VALUES (1, 'call_1', 'search_students', 'success')", - ); - - await dataSource.query('DELETE FROM ai_conversations WHERE id = 1'); - - expect(await dataSource.query('SELECT id FROM ai_messages')).toEqual([]); - expect(await dataSource.query('SELECT id FROM ai_tool_runs')).toEqual([]); - }); -}); diff --git a/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts b/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts deleted file mode 100644 index 6db122c..0000000 --- a/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddA2UiReviews1784880000000 } from '../migrations/1784880000000-AddA2UiReviews'; -import { EnlargeAiReviewSections1784900000000 } from '../migrations/1784900000000-EnlargeAiReviewSections'; - -describe('EnlargeAiReviewSections1784900000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddA2UiReviews1784880000000, EnlargeAiReviewSections1784900000000], - }); - await dataSource.initialize(); - await dataSource.query(` - CREATE TABLE ai_messages ( - id integer PRIMARY KEY AUTOINCREMENT, - conversation_id integer NOT NULL, - role varchar(20) NOT NULL, - content text, - reasoning_content text, - status varchar(20) NOT NULL, - error_code varchar(50), - reply_to_message_id integer, - feedback varchar(10), - feedback_reason varchar(500), - metadata text, - created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('迁移后可保存远超 256KB 的预览数据,且再次执行幂等', async () => { - await dataSource.runMigrations(); - await dataSource.runMigrations(); - - await dataSource.query( - `INSERT INTO ai_messages (conversation_id, role, content, status) - VALUES (1, 'assistant', '', 'completed')`, - ); - const big = '中'.repeat(300 * 1024); - await dataSource.query( - `INSERT INTO ai_reviews - (id, conversation_id, user_id, assistant_message_id, title, sections_json, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ['review-1', 1, 1, 1, '大体积导入', big, 'pending'], - ); - const rows: Array<{ sections_json: string }> = await dataSource.query( - 'SELECT sections_json FROM ai_reviews WHERE id = ?', - ['review-1'], - ); - expect(rows[0].sections_json.length).toBe(big.length); - - const runner = dataSource.createQueryRunner(); - expect(await runner.hasColumn('ai_reviews', 'sections_json')).toBe(true); - await runner.release(); - }); -}); diff --git a/apps/server/src/ai-chat/ai-review.service.spec.ts b/apps/server/src/ai-chat/ai-review.service.spec.ts index 4b327a4..1ce6b94 100644 --- a/apps/server/src/ai-chat/ai-review.service.spec.ts +++ b/apps/server/src/ai-chat/ai-review.service.spec.ts @@ -637,696 +637,3 @@ describe('AiReviewService', () => { }); }); -describe('AiReviewService.submit (real sqlite transaction)', () => { - let dataSource: DataSource; - let service: AiReviewService; - let hostOrg: Organization; - let namedOrg: Organization; - let assistantMessageId: number; - - beforeAll(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - entities: Object.values(allEntities).filter( - (value): value is new (...args: unknown[]) => unknown => - typeof value === 'function', - ), - synchronize: true, - }); - await dataSource.initialize(); - const orgRepo = dataSource.getRepository(Organization); - hostOrg = await orgRepo.save( - orgRepo.create({ publicId: 'host', code: 'HOST', name: '恭学总校', isHost: true }), - ); - namedOrg = await orgRepo.save( - orgRepo.create({ publicId: 'org-a', code: 'ORG_A', name: '东校区' }), - ); - const userRepo = dataSource.getRepository(User); - const user = await userRepo.save( - userRepo.create({ username: 'review-tester', passwordHash: 'x' }), - ); - const conversationRepo = dataSource.getRepository(AiConversation); - const conversation = await conversationRepo.save( - conversationRepo.create({ userId: user.id, title: '测试会话' }), - ); - const messageRepo = dataSource.getRepository(AiMessage); - const assistant = await messageRepo.save( - messageRepo.create({ - conversationId: conversation.id, - role: 'assistant', - content: '', - status: 'completed', - }), - ); - assistantMessageId = assistant.id; - const reviewRepo = dataSource.getRepository(AiReview); - service = new AiReviewService(reviewRepo, dataSource); - }); - - afterAll(async () => { - await dataSource.destroy(); - }); - - it('按 学生→宿舍→换宿 顺序事务入库,并收集逐行问题', async () => { - const studentRepo = dataSource.getRepository(Student); - const roomRepo = dataSource.getRepository(Room); - const bedRepo = dataSource.getRepository(Bed); - const occRepo = dataSource.getRepository(Occupancy); - - const existing = await studentRepo.save( - studentRepo.create({ - name: '老王', - phone: '13800138000', - studentNo: 'S001', - organizationId: hostOrg.id, - }), - ); - const oldRoom = await roomRepo.save( - roomRepo.create({ roomNumber: '1-101', capacity: 4, status: 'available' }), - ); - await bedRepo.save( - Array.from({ length: 4 }, (_, index) => - bedRepo.create({ roomId: oldRoom.id, bedNumber: `${index + 1}号床` }), - ), - ); - await occRepo.save( - occRepo.create({ - studentId: existing.id, - roomId: oldRoom.id, - checkInDate: '2026-01-05', - billingStartDate: '2026-01-05', - stayType: 'short', - responsibleOrganizationId: hostOrg.id, - }), - ); - - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '开学导入', - summary: 'Excel 导入', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'organization', title: '机构' }, - ], - rows: [ - { name: '张三', phone: '13900139000', organization: '东校区' }, - { name: '老王', phone: '13800138000', organization: '恭学总校' }, - { name: '李四', phone: '13700137000', organization: '不存在的机构' }, - ], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '3-301' }, { roomNumber: '1-101' }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentNo', title: '学号' }, - { key: 'oldRoom', title: '原宿舍' }, - { key: 'newRoom', title: '目标宿舍' }, - { key: 'transferDate', title: '换宿日期' }, - ], - rows: [ - { studentNo: 'S001', oldRoom: '1-101', newRoom: '3-301', transferDate: '2026-03-01' }, - ], - issues: [], - }, - ], - }, - ); - - const { review: submittedReview, result } = await service.submitAll(review.id, 7); - expect(result.students.created).toBe(2); - expect(result.students.skipped).toBe(1); - expect(result.rooms.created).toBe(1); - expect(result.rooms.skipped).toBe(1); - expect(result.transfers.completed).toBe(1); - expect(result.transfers.skipped).toBe(0); - - const createdStudent = await studentRepo.findOne({ where: { phone: '13900139000' } }); - expect(createdStudent?.name).toBe('张三'); - expect(createdStudent?.organizationId).toBe(namedOrg.id); - const hostFallbackStudent = await studentRepo.findOne({ where: { phone: '13700137000' } }); - expect(hostFallbackStudent?.organizationId).toBe(hostOrg.id); - - const newRoom = await roomRepo.findOne({ where: { roomNumber: '3-301' } }); - expect(newRoom?.capacity).toBe(4); - expect(await bedRepo.count({ where: { roomId: newRoom!.id } })).toBe(4); - - const oldOcc = await occRepo.findOne({ - where: { studentId: existing.id, roomId: oldRoom.id }, - }); - expect(oldOcc?.checkOutDate).toBe('2026-03-01'); - const newOcc = await occRepo.findOne({ - where: { studentId: existing.id, roomId: newRoom!.id, checkOutDate: null }, - }); - expect(newOcc?.checkInDate).toBe('2026-03-01'); - expect(newOcc?.billingStartDate).toBe('2026-03-02'); - - expect(submittedReview.status).toBe('submitted'); - expect(submittedReview.submittedAt).toBeInstanceOf(Date); - const savedSections = service.parseSections(submittedReview.sectionsJson); - const studentSection = savedSections.find((section) => section.key === 'students'); - expect(studentSection?.issues).toEqual( - expect.arrayContaining(['学生「老王」已存在(按手机号/学号匹配),未重复创建']), - ); - expect(submittedReview.resultSummary).toContain('成功导入学生 2 人'); - }); - - it('入住记录分表:学生和宿舍不存在时自动创建后写入住记录', async () => { - const studentRepo = dataSource.getRepository(Student); - const roomRepo = dataSource.getRepository(Room); - const occRepo = dataSource.getRepository(Occupancy); - - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '入住导入', - summary: '宿舍入住记录', - sections: [ - { - key: 'checkins', - title: '入住记录', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: '於嘉丽', - phone: '13611112222', - roomNumber: '5-501', - checkInDate: '2026-08-01', - }, - { - name: '重复学生', - phone: '13611112222', - roomNumber: '5-502', - checkInDate: '2026-08-01', - }, - ], - issues: [], - }, - ], - }, - ); - - const { result } = await service.submitAll(review.id, 7); - expect(result.checkins.completed).toBe(1); - expect(result.checkins.skipped).toBe(1); - - const created = await studentRepo.findOne({ where: { phone: '13611112222' } }); - expect(created?.name).toBe('於嘉丽'); - expect(created?.organizationId).toBe(hostOrg.id); - const room = await roomRepo.findOne({ where: { roomNumber: '5-501' } }); - expect(room?.capacity).toBe(4); - const occupancy = await occRepo.findOne({ where: { studentId: created!.id } }); - expect(occupancy?.checkInDate).toBe('2026-08-01'); - expect(occupancy?.roomId).toBe(room!.id); - }); - - it('分步确认:依赖未满足拒绝,重复确认 409,全部完成后整卡提交', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '分步导入', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '分步学生', phone: '13511112222' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-901' }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [{ key: 'studentNo', title: '学号' }], - rows: [{ studentNo: 'NOPE' }], - issues: [], - }, - ], - }, - ); - - await expect(service.submitSection(review.id, 7, 'transfers')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - - const studentsStep = await service.submitSection(review.id, 7, 'students'); - expect(studentsStep.result).toMatchObject({ created: 1, skipped: 0 }); - expect( - service - .parseSections(studentsStep.review.sectionsJson) - .find((section) => section.key === 'students')?.status, - ).toBe('submitted'); - - await expect(service.submitSection(review.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已确认导入'), - }); - - await service.submitSection(review.id, 7, 'rooms'); - const transferStep = await service.submitSection(review.id, 7, 'transfers'); - expect(service.parseSections(transferStep.review.sectionsJson).map((s) => s.status)).toEqual([ - 'submitted', - 'submitted', - 'submitted', - ]); - expect(transferStep.review.status).toBe('submitted'); - expect(transferStep.review.submittedAt).toBeInstanceOf(Date); - }); - - it('依赖按类型整组判断:同类型全部 sheet 提交后才允许换宿', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '多 sheet 依赖', - sections: [ - { - key: 'students_a', - title: '学生 A', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '甲', phone: '13511112222' }], - issues: [], - }, - { - key: 'students_b', - title: '学生 B', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '乙', phone: '13511113333' }], - issues: [], - }, - { - key: 'rooms_9', - title: '9 号楼宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-901' }], - issues: [], - }, - { - key: 'checkins_active', - type: 'checkins', - title: '在住记录', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: '丙', - phone: '13511115555', - roomNumber: '9-903', - checkInDate: '2026-08-01', - }, - ], - issues: [], - }, - { - key: 'transfers_9', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentPhone', title: '手机号' }, - { key: 'newRoom', title: '目标宿舍' }, - { key: 'transferDate', title: '换宿日期' }, - ], - rows: [{ studentPhone: '13511115555', newRoom: '9-901', transferDate: '2026-08-10' }], - issues: [], - }, - ], - }, - ); - - await expect(service.submitSection(review.id, 7, 'transfers_9')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - - await service.submitSection(review.id, 7, 'students_a'); - await expect(service.submitSection(review.id, 7, 'transfers_9')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 2 步'), - }); - - await service.submitSection(review.id, 7, 'students_b'); - await service.submitSection(review.id, 7, 'rooms_9'); - await service.submitSection(review.id, 7, 'checkins_active'); - const transferStep = await service.submitSection(review.id, 7, 'transfers_9'); - expect(transferStep.result).toMatchObject({ completed: 1, skipped: 0 }); - const statuses = service - .parseSections(transferStep.review.sectionsJson) - .map((section) => section.status); - expect(statuses).toEqual(['submitted', 'submitted', 'submitted', 'submitted', 'submitted']); - }); - - it('组确认按 sheet 逐张导入,成功后整组状态已导入', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '整组入住确认', - sections: Array.from({ length: 2 }, (_, i) => ({ - key: `checkins_group_${i + 1}`, - type: 'checkins', - title: `入住表${i + 1}`, - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: `入住学生${i + 1}`, - phone: `1360000000${i + 1}`, - roomNumber: `5-50${i + 1}`, - checkInDate: '2026-08-01', - }, - ], - issues: [], - })), - }, - ); - - const { review: grouped } = await service.submitGroup(review.id, 7, 'checkins'); - const sections = service.parseSections(grouped.sectionsJson); - expect(sections.map((section) => section.status)).toEqual(['submitted', 'submitted']); - expect(grouped.status).toBe('submitted'); - expect( - await dataSource.getRepository(Student).count({ - where: { phone: '13600000001' }, - }), - ).toBe(1); - expect( - await dataSource.getRepository(Student).count({ - where: { phone: '13600000002' }, - }), - ).toBe(1); - expect(await dataSource.getRepository(Room).count({ where: { roomNumber: '5-501' } })).toBe(1); - expect(await dataSource.getRepository(Room).count({ where: { roomNumber: '5-502' } })).toBe(1); - }); - - it('全部确认时按类型合并多张 sheet 的统计数量', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '多 sheet 聚合', - sections: Array.from({ length: 2 }, (_, i) => ({ - key: `students_batch_${i + 1}`, - type: 'students', - title: `学生表${i + 1}`, - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [ - { - name: `批量学生${i + 1}`, - phone: `1370000000${i + 1}`, - }, - ], - issues: [], - })), - }, - ); - - const { result } = await service.submitAll(review.id, 7); - expect(result.students.created).toBe(2); - expect(result.students.skipped).toBe(0); - expect(result.message).toContain('成功导入学生 2 人'); - }); - - it('组确认依赖未满足时返回 409,不导入任何 sheet', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '组依赖校验', - sections: [ - { - key: 'students_a', - title: '学生 A', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '甲' }], - issues: [], - }, - { - key: 'transfers_a', - title: '换宿 A', - kind: 'table', - columns: [{ key: 'studentPhone', title: '手机号' }], - rows: [{ studentPhone: '13511114444' }], - issues: [], - }, - ], - }, - ); - await expect(service.submitGroup(review.id, 7, 'transfers')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - const sections = service.parseSections((await service.findOwned(review.id, 7)).sectionsJson); - expect(sections.map((section) => section.status)).toEqual(['pending', 'pending']); - }); - - it('单步确认部分成功时持久化 resultSummary 与问题', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '部分成功', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [ - { name: '新学生', phone: '13522223333' }, - { name: '重复学生', phone: '13522223333' }, - ], - issues: [], - }, - ], - }, - ); - - const step = await service.submitSection(review.id, 7, 'students'); - expect(step.result).toMatchObject({ created: 1, skipped: 1 }); - const saved = await service.findOwned(review.id, 7); - const section = service.parseSections(saved.sectionsJson)[0]; - expect(section.status).toBe('submitted'); - expect(section.resultSummary).toContain('成功导入学生 1 人,跳过 1 条'); - expect(section.issues).toEqual( - expect.arrayContaining([expect.stringContaining('同一批次中的其他学生')]), - ); - expect(saved.status).toBe('submitted'); - }); - - it('全部确认按固定依赖顺序提交,不受 sections 原始顺序影响', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '乱序导入', - sections: [ - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [{ key: 'studentNo', title: '学号' }], - rows: [{ studentNo: 'NOPE' }], - issues: [], - }, - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '乱序学生', phone: '13544445555' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-902' }], - issues: [], - }, - ], - }, - ); - - const { review: completed } = await service.submitAll(review.id, 7); - expect(completed.status).toBe('submitted'); - expect(service.parseSections(completed.sectionsJson).map((section) => section.status)).toEqual([ - 'submitted', - 'submitted', - 'submitted', - ]); - }); - - it('旧数据缺少 section status 字段时默认 pending 并可继续确认', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '旧数据兼容', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '旧数据学生', phone: '13533334444' }], - issues: [], - }, - ], - }, - ); - const legacySections = service - .parseSections(review.sectionsJson) - .map( - ({ status: _status, resultSummary: _result, submittedAt: _at, type: _type, ...rest }) => - rest, - ); - review.sectionsJson = JSON.stringify(legacySections); - await dataSource.getRepository(AiReview).save(review); - - const step = await service.submitSection(review.id, 7, 'students'); - expect(step.result).toMatchObject({ created: 1, skipped: 0 }); - const reloaded = service.parseSections((await service.findOwned(review.id, 7)).sectionsJson)[0]; - expect(reloaded.status).toBe('submitted'); - }); - - it('同会话生成新预览后旧预览过期,且所有确认入口拒绝', async () => { - const conversationId = 9001; - const first = await service.createReview( - { ...baseArgs, conversationId, assistantMessageId }, - { - title: '旧预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '旧学生', phone: '13711110001' }], - issues: [], - }, - ], - }, - ); - const second = await service.createReview( - { ...baseArgs, conversationId, assistantMessageId }, - { - title: '新预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '新学生', phone: '13711110002' }], - issues: [], - }, - ], - }, - ); - - const expired = await service.expirePreviousReviews(7, conversationId, second.id); - expect(expired.map((review) => review.id)).toEqual([first.id]); - expect((await service.findOwned(first.id, 7)).status).toBe('expired'); - expect((await service.findOwned(second.id, 7)).status).toBe('pending'); - - await expect(service.findOwnedPending(first.id, 7)).rejects.toThrow('已失效'); - await expect(service.submitSection(first.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - await expect(service.submitGroup(first.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - await expect(service.submitAll(first.id, 7)).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - }); - - it('不同会话的旧预览不会被其他会话的新预览过期', async () => { - const first = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: 'A 会话预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '跨会话学生', phone: '13711110003' }], - issues: [], - }, - ], - }, - ); - - await service.expirePreviousReviews(7, 999, 'other-review'); - expect((await service.findOwned(first.id, 7)).status).toBe('pending'); - }); -}); diff --git a/apps/server/src/ai-config/ai-config.service.ts b/apps/server/src/ai-config/ai-config.service.ts index 4536948..1f3bd6a 100644 --- a/apps/server/src/ai-config/ai-config.service.ts +++ b/apps/server/src/ai-config/ai-config.service.ts @@ -78,8 +78,7 @@ export class AiConfigService implements AiConfigProbeContext { const code = isErrWithCode ? (err as Record).code : undefined; const errno = isErrWithCode ? (err as Record).errno : undefined; // MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062 - // SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT') - if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') { + if (code === 'ER_DUP_ENTRY' || errno === 1062) { const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } }); if (existing) return existing; } diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c90aca7..0cd513c 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -91,7 +91,6 @@ import { IntegrationConfigModule } from './integration/config/config.module'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => { - const dbType = config.get('DB_TYPE', 'sqlite'); const allEntities = [ Entities.Student, Entities.Room, @@ -151,26 +150,17 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.ImportStep, Entities.ImportRow, ]; - if (dbType === 'mysql') { - return { - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'dorm_billing'), - entities: allEntities, - migrations: allMigrations, - synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', - charset: 'utf8mb4', - }; - } return { - type: 'better-sqlite3' as const, - database: config.get('DB_DATABASE', 'dorm_billing.db'), - migrations: allMigrations, + type: 'mysql' as const, + host: config.get('DB_HOST', 'localhost'), + port: config.get('DB_PORT', 3306), + username: config.get('DB_USERNAME', 'root'), + password: config.get('DB_PASSWORD', ''), + database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, + migrations: allMigrations, synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', + charset: 'utf8mb4', }; }, }), diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index 632f6c2..5dafa4a 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -244,8 +244,8 @@ export class AttendanceLessonService { } catch (err: unknown) { const code = (err as Record).code; const errno = (err as Record).errno; - // MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT - if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') { + // MySQL: ER_DUP_ENTRY or errno 1062 + if (code === 'ER_DUP_ENTRY' || errno === 1062) { const existing = await sessionRepo.findOne({ where: { scheduleId, lessonDate }, }); diff --git a/apps/server/src/attendance/attendance-workflow.integration.spec.ts b/apps/server/src/attendance/attendance-workflow.integration.spec.ts deleted file mode 100644 index 9fb9de2..0000000 --- a/apps/server/src/attendance/attendance-workflow.integration.spec.ts +++ /dev/null @@ -1,332 +0,0 @@ -import type { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import request from 'supertest'; -import type { Repository } from 'typeorm'; -import { AppModule } from '../app.module'; -import type { DingTalkAttendanceResult } from '../integration/dingtalk.service'; -import { DingTalkService } from '../integration/dingtalk.service'; -import { - AttendanceRecord, - Organization, - Role, - Student, - StudentDingMapping, - User, -} from '../entities'; -import { createStudentImportTemplateWorkbook } from '../students/student-import'; - -const LESSON_DATE = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', -}).format(new Date()); -const STUDENT_A_DING_ID = 'integration-student-a'; -const STUDENT_B_DING_ID = 'integration-student-b'; - -const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); - -function chinaWeekDay(date: string): number { - const day = new Date(`${date}T00:00:00+08:00`).getDay(); - return day === 0 ? 7 : day; -} - -function attendanceResult( - userId: string, - checkId: string, - actualCheckTime: string, -): DingTalkAttendanceResult { - return { - userId, - userName: '', - workDate: LESSON_DATE, - timeResult: 'Normal', - locationResult: 'Normal', - planCheckTime: `${LESSON_DATE}T00:00:00+08:00`, - actualCheckTime, - checkId, - checkType: 'OnDuty', - sourceType: 'ATM', - deviceName: '集成测试考勤机', - deviceId: 'integration-device', - }; -} - -// Requires a fully configured attendance integration and is intentionally excluded from routine CI. -describe.skip('attendance workflow integration', () => { - let app: INestApplication; - let adminToken: string; - let teacherToken: string; - let mockedPunches: DingTalkAttendanceResult[]; - const originalEnv = { - DB_TYPE: process.env.DB_TYPE, - DB_DATABASE: process.env.DB_DATABASE, - DB_SYNCHRONIZE: process.env.DB_SYNCHRONIZE, - SEED_DEV: process.env.SEED_DEV, - ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, - }; - - beforeAll(async () => { - process.env.DB_TYPE = 'sqlite'; - process.env.DB_DATABASE = ':memory:'; - process.env.DB_SYNCHRONIZE = 'true'; - process.env.SEED_DEV = 'true'; - process.env.ADMIN_PASSWORD = 'admin123'; - - mockedPunches = []; - const dingTalk = { - fetchAttendanceResults: jest.fn(async () => mockedPunches), - }; - - const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(DingTalkService) - .useValue(dingTalk) - .compile(); - - app = moduleRef.createNestApplication(); - app.setGlobalPrefix('api'); - await app.init(); - - const login = await request(app.getHttpServer()) - .post('/api/auth/login') - .send({ username: 'admin', password: 'admin123' }) - .expect(201); - adminToken = login.body.access_token; - }); - - afterAll(async () => { - await app?.close(); - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it('imports students, builds a teacher class schedule, refreshes punches, and scopes reads', async () => { - const roleRepo = app.get>(getRepositoryToken(Role)); - const userRepo = app.get>(getRepositoryToken(User)); - const studentRepo = app.get>(getRepositoryToken(Student)); - const mappingRepo = app.get>( - getRepositoryToken(StudentDingMapping), - ); - const organizationRepo = app.get>(getRepositoryToken(Organization)); - const attendanceRepo = app.get>( - getRepositoryToken(AttendanceRecord), - ); - - const teacherRole = await roleRepo.findOneByOrFail({ code: 'teacher' }); - const teacherCreate = await request(app.getHttpServer()) - .post('/api/rbac/users') - .set(auth(adminToken)) - .send({ - username: 'integration-teacher', - password: 'teacher123', - name: '集成测试任课教师', - roleIds: [teacherRole.id], - }) - .expect(201); - expect(teacherCreate.body.message).toBe('用户创建成功'); - - const teacher = await userRepo.findOneByOrFail({ username: 'integration-teacher' }); - const teacherId = teacher.id; - const teacherLogin = await request(app.getHttpServer()) - .post('/api/auth/login') - .send({ username: 'integration-teacher', password: 'teacher123' }) - .expect(201); - teacherToken = teacherLogin.body.access_token; - - const host = await organizationRepo.findOneByOrFail({ isHost: true, status: 'active' }); - const workbook = createStudentImportTemplateWorkbook(); - const sheet = workbook.getWorksheet('学生基础+档案+录取')!; - sheet.spliceRows(2, 1); - sheet.addRow({ - phone: '13800000001', - name: '集成学生甲', - studentNo: 'IT001', - organization: host.name, - }); - sheet.addRow({ - phone: '13800000002', - name: '集成学生乙', - studentNo: 'IT002', - organization: host.name, - }); - const workbookBuffer = Buffer.from(await workbook.xlsx.writeBuffer()); - - const importResult = await request(app.getHttpServer()) - .post('/api/students/import') - .set(auth(adminToken)) - .attach('file', workbookBuffer, { - filename: 'attendance-workflow-students.xlsx', - contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }) - .expect(201); - expect(importResult.body).toMatchObject({ imported: 2, skipped: 0 }); - - const [studentA, studentB] = await Promise.all([ - studentRepo.findOneByOrFail({ phone: '13800000001' }), - studentRepo.findOneByOrFail({ phone: '13800000002' }), - ]); - await mappingRepo.save([ - mappingRepo.create({ dingUserId: STUDENT_A_DING_ID, studentId: studentA.id }), - mappingRepo.create({ dingUserId: STUDENT_B_DING_ID, studentId: studentB.id }), - ]); - - const classResult = await request(app.getHttpServer()) - .post('/api/classes') - .set(auth(adminToken)) - .send({ - name: '集成考勤班', - code: 'ATTENDANCE-INTEGRATION', - classType: 'culture', - status: 'active', - startDate: LESSON_DATE, - endDate: LESSON_DATE, - }) - .expect(201); - const classId = classResult.body.id; - - await request(app.getHttpServer()) - .post(`/api/classes/${classId}/students`) - .set(auth(adminToken)) - .send({ studentIds: [studentA.id, studentB.id] }) - .expect(201) - .expect(({ body }) => expect(body).toMatchObject({ added: 2, skipped: 0 })); - - await request(app.getHttpServer()) - .post(`/api/classes/${classId}/teachers`) - .set(auth(adminToken)) - .send({ userId: teacherId, roleType: 'subject_teacher', subject: '语文' }) - .expect(201); - - const classroomResult = await request(app.getHttpServer()) - .post('/api/classrooms') - .set(auth(adminToken)) - .send({ name: '集成测试教室', building: '测试楼', floor: 1, capacity: 30, roomType: '小' }) - .expect(201); - - const scheduleResult = await request(app.getHttpServer()) - .post('/api/class-schedules') - .set(auth(adminToken)) - .send({ - classId, - classroomId: classroomResult.body.id, - weekDay: chinaWeekDay(LESSON_DATE), - startTime: '00:00', - endTime: '23:59', - attendanceAdvanceMinutes: 0, - startDate: LESSON_DATE, - endDate: LESSON_DATE, - subject: '语文', - teacherId, - scheduleType: 'INTERNAL', - }) - .expect(201); - const scheduleId = scheduleResult.body.id; - - const initialPull = await request(app.getHttpServer()) - .post(`/api/attendance-lessons/schedules/${scheduleId}/pull`) - .set(auth(teacherToken)) - .send({ date: LESSON_DATE }) - .expect(201); - expect(initialPull.body.records).toHaveLength(2); - expect(initialPull.body.records.map((record: AttendanceRecord) => record.status)).toEqual([ - 'pending', - 'pending', - ]); - - const studentARecord = initialPull.body.records.find( - (record: AttendanceRecord) => record.studentId === studentA.id, - ); - await request(app.getHttpServer()) - .put(`/api/attendance-records/${studentARecord.id}`) - .set(auth(teacherToken)) - .send({ status: 'absent', remark: '教师本地覆盖' }) - .expect(200) - .expect(({ body }) => expect(body).toMatchObject({ status: 'absent', source: 'manual' })); - - mockedPunches = [ - attendanceResult(STUDENT_A_DING_ID, 'integration-check-a', `${LESSON_DATE}T01:00:00.000Z`), - attendanceResult(STUDENT_B_DING_ID, 'integration-check-b', `${LESSON_DATE}T01:05:00.000Z`), - ]; - - const refreshed = await request(app.getHttpServer()) - .post(`/api/attendance-lessons/schedules/${scheduleId}/pull`) - .set(auth(teacherToken)) - .send({ date: LESSON_DATE }) - .expect(201); - expect(refreshed.body.records).toHaveLength(2); - expect( - refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentA.id), - ).toMatchObject({ status: 'absent', source: 'manual', remark: '教师本地覆盖' }); - expect( - refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentB.id), - ).toMatchObject({ status: 'present', source: 'dingtalk', punchSource: 'ATM' }); - - const teacherRecords = await request(app.getHttpServer()) - .get( - `/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`, - ) - .set(auth(teacherToken)) - .expect(200); - expect(teacherRecords.body.list).toHaveLength(2); - const teacherView: Array> = - teacherRecords.body.list - .map((record: AttendanceRecord) => ({ - studentId: record.studentId, - status: record.status, - source: record.source, - })) - .sort((left, right) => left.studentId - right.studentId); - expect(teacherView).toEqual([ - { studentId: studentA.id, status: 'absent', source: 'manual' }, - { studentId: studentB.id, status: 'present', source: 'dingtalk' }, - ]); - - const adminRecords = await request(app.getHttpServer()) - .get( - `/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`, - ) - .set(auth(adminToken)) - .expect(200); - expect(adminRecords.body.list).toHaveLength(2); - expect( - adminRecords.body.list - .map((record: AttendanceRecord) => ({ - studentId: record.studentId, - status: record.status, - source: record.source, - })) - .sort( - (left: Pick, right: Pick) => - left.studentId - right.studentId, - ), - ).toEqual(teacherView); - - const unassignedClass = await request(app.getHttpServer()) - .post('/api/classes') - .set(auth(adminToken)) - .send({ - name: '未分配教师班级', - code: 'UNASSIGNED-INTEGRATION', - classType: 'culture', - status: 'active', - }) - .expect(201); - await request(app.getHttpServer()) - .get(`/api/attendance-records?classId=${unassignedClass.body.id}`) - .set(auth(teacherToken)) - .expect(400); - - const persisted = await attendanceRepo.find({ - where: { classId }, - order: { studentId: 'ASC' }, - }); - expect(persisted).toHaveLength(2); - expect(persisted).toEqual([ - expect.objectContaining({ studentId: studentA.id, status: 'absent', source: 'manual' }), - expect.objectContaining({ studentId: studentB.id, status: 'present', source: 'dingtalk' }), - ]); - }); -}); diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index ecc8887..4509b9d 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -577,9 +577,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { }); // Simulate unique constraint on save sessionRepo.save.mockRejectedValueOnce( - Object.assign(new Error('UNIQUE constraint failed'), { - code: 'SQLITE_CONSTRAINT', - errno: undefined, + Object.assign(new Error('Duplicate entry'), { + code: 'ER_DUP_ENTRY', + errno: 1062, }), ); classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); diff --git a/apps/server/src/database/attendance-fk-restrict.spec.ts b/apps/server/src/database/attendance-fk-restrict.spec.ts deleted file mode 100644 index 81f915d..0000000 --- a/apps/server/src/database/attendance-fk-restrict.spec.ts +++ /dev/null @@ -1,380 +0,0 @@ -import Database from 'better-sqlite3'; -type SqliteDB = InstanceType; - -/** - * Real SQLite foreign-key constraint tests. - * - * These tests use the `better-sqlite3` driver directly (in-memory) to verify - * that ON DELETE RESTRICT is enforced at the database level, not just in - * application-layer guards. - */ -describe('attendance_sessions FK RESTRICT — real SQLite', () => { - let db: SqliteDB; - - function createSchema(): void { - db.exec('PRAGMA foreign_keys = ON'); - db.exec(` - CREATE TABLE IF NOT EXISTS classes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - is_archived INTEGER DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS class_schedule ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class_id INTEGER, - week_day INTEGER NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status TEXT DEFAULT 'in_progress', - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - } - - beforeEach(() => { - db = new Database(':memory:'); - createSchema(); - }); - - afterEach(() => { - db.close(); - }); - - it('blocks class deletion when attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).toThrow(); - }); - - it('allows class deletion when no attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).not.toThrow(); - - const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as { - cnt: number; - }; - expect(remaining.cnt).toBe(0); - }); - - it('blocks schedule deletion when attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - expect(() => { - db.exec('DELETE FROM class_schedule WHERE id = 1'); - }).toThrow(); - }); - - it('PRAGMA foreign_key_list confirms both FKs are present', () => { - // Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks - const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{ - id: number; - seq: number; - table: string; - from: string; - to: string; - on_update: string; - on_delete: string; - match: string; - }>; - - expect(rows.length).toBe(2); - - const scheduleFk = rows.find((fk) => fk.from === 'schedule_id'); - expect(scheduleFk).toBeDefined(); - expect(scheduleFk!.table).toBe('class_schedule'); - expect(scheduleFk!.on_delete).toBe('RESTRICT'); - - const classFk = rows.find((fk) => fk.from === 'class_id'); - expect(classFk).toBeDefined(); - expect(classFk!.table).toBe('classes'); - expect(classFk!.on_delete).toBe('RESTRICT'); - }); - - it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - // Verify the session exists - const session = db - .prepare('SELECT * FROM attendance_sessions WHERE class_id = 1') - .get() as Record; - expect(session).toBeDefined(); - - // Delete should fail - expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow(); - - // Session should still exist after failed delete - const after = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1') - .get() as { cnt: number }; - expect(after.cnt).toBe(1); - }); -}); - -/** - * Integration test: simulate the protectAttendanceHistory SQLite migration. - * - * Creates tables WITHOUT foreign keys (pre-migration state), inserts parent - * session and child attendance_record, runs the table-rebuild migration - * (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON, - * foreign_key_check), then verifies: - * - attendance_record.attendance_session_id is preserved - * - RESTRICT still blocks class/schedule deletion - */ -describe('protectAttendanceHistory SQLite migration — integration', () => { - let db: SqliteDB; - - function createPreMigrationSchema(): void { - // Schema WITHOUT foreign keys on attendance_sessions (pre-migration) - db.exec('PRAGMA foreign_keys = ON'); - db.exec(` - CREATE TABLE IF NOT EXISTS classes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - is_archived INTEGER DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS class_schedule ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class_id INTEGER, - week_day INTEGER NOT NULL - ) - `); - // attendance_sessions WITHOUT foreign keys - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status TEXT DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - // Legacy columns came first; course-attendance columns were appended later. - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - schedule_id INTEGER, - attendance_session_id INTEGER - ) - `); - } - - function runMigration(): void { - // Step 1: PRAGMA foreign_keys = OFF outside transaction - db.exec('PRAGMA foreign_keys = OFF'); - try { - db.exec('BEGIN'); - try { - // Rebuild attendance_sessions with FKs - db.exec(` - CREATE TABLE attendance_sessions_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - db.exec( - 'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions', - ); - db.exec('DROP TABLE attendance_sessions'); - db.exec( - 'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions', - ); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - - // Rebuild attendance_records with FK on attendance_session_id - const recordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all() as Array<{ from: string }>; - const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id'); - if (!hasSessionFk) { - db.exec(` - CREATE TABLE attendance_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - schedule_id INTEGER, - attendance_session_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL - ) - `); - db.exec(` - INSERT INTO attendance_records_new ( - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - ) - SELECT - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - FROM attendance_records - `); - db.exec('DROP TABLE attendance_records'); - db.exec( - 'ALTER TABLE attendance_records_new RENAME TO attendance_records', - ); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } - - db.exec('COMMIT'); - } catch (err) { - db.exec('ROLLBACK'); - throw err; - } - } finally { - db.exec('PRAGMA foreign_keys = ON'); - } - - // Run foreign_key_check — should be clean - const checkRows = db.prepare('PRAGMA foreign_key_check').all(); - if (checkRows.length > 0) { - throw new Error( - `外键一致性检查失败: ${checkRows.length} 行违反外键约束`, - ); - } - } - - beforeEach(() => { - db = new Database(':memory:'); - createPreMigrationSchema(); - }); - - afterEach(() => { - db.close(); - }); - - it('preserves attendance_record.session_id after migration', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - db.exec( - "INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')", - ); - - // Verify pre-migration state - const preSessionFk = db - .prepare("PRAGMA foreign_key_list('attendance_sessions')") - .all(); - expect(preSessionFk.length).toBe(0); - - const preRecordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all(); - expect(preRecordsFk.length).toBe(0); - - // Run migration - runMigration(); - - // Verify attendance_record still has correct attendance_session_id - const record = db - .prepare('SELECT * FROM attendance_records WHERE id = 1') - .get() as Record; - expect(record).toBeDefined(); - expect(record.attendance_session_id).toBe(1); - expect(record.attendance_date).toBe('2026-01-01'); - expect(record.session).toBe('morning'); - expect(record.status).toBe('present'); - - // Verify FKs now exist on both tables - const postSessionFk = db - .prepare("PRAGMA foreign_key_list('attendance_sessions')") - .all(); - expect(postSessionFk.length).toBe(2); - - const postRecordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all() as Array<{ from: string; table: string; on_delete: string }>; - const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id'); - expect(sessionFk).toBeDefined(); - expect(sessionFk!.table).toBe('attendance_sessions'); - expect(sessionFk!.on_delete).toBe('SET NULL'); - - // RESTRICT still blocks class/schedule deletion - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).toThrow(); - expect(() => { - db.exec('DELETE FROM class_schedule WHERE id = 1'); - }).toThrow(); - - // Verify data survived the failed deletes - const sessionAfter = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1') - .get() as { cnt: number }; - expect(sessionAfter.cnt).toBe(1); - - const recordAfter = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1') - .get() as { cnt: number }; - expect(recordAfter.cnt).toBe(1); - - const classAfter = db - .prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1') - .get() as { cnt: number }; - expect(classAfter.cnt).toBe(1); - }); -}); diff --git a/apps/server/src/database/database-migrations.ai.ts b/apps/server/src/database/database-migrations.ai.ts index 77d77a8..d49a403 100644 --- a/apps/server/src/database/database-migrations.ai.ts +++ b/apps/server/src/database/database-migrations.ai.ts @@ -8,14 +8,11 @@ export async function ensureAiConfigTable( ): Promise { await withQueryRunner(dataSource, async (runner) => { const tables = await runner.getTables(['ai_config']); - const isMySQL = dataSource.options.type === 'mysql'; if (tables.length === 0) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN'; - const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP'; + const pkDef = 'id INTEGER PRIMARY KEY AUTO_INCREMENT'; + const boolType = 'TINYINT(1)'; + const datetimeFn = 'CURRENT_TIMESTAMP'; await runner.query(` CREATE TABLE ai_config ( @@ -38,18 +35,12 @@ export async function ensureAiConfigTable( ) `); - if (isMySQL) { - try { - await runner.query( - 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', - ); - } catch { - // Index may already exist; MySQL has no IF NOT EXISTS for indexes - } - } else { + try { await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)', + 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', ); + } catch { + // Index may already exist; MySQL has no IF NOT EXISTS for indexes } logger.log('已创建 ai_config 表'); @@ -67,10 +58,10 @@ export async function ensureAiConfigTable( { name: 'api_key_auth_tag', def: 'VARCHAR(50)' }, { name: 'key_last4', def: 'VARCHAR(4)' }, { name: 'default_model', def: 'VARCHAR(100)' }, - { name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'enabled', def: 'TINYINT(1) DEFAULT 0' }, { name: 'timeout_ms', def: 'INT DEFAULT 30000' }, { name: 'reasoning_effort', def: 'VARCHAR(20)' }, - { name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'verified', def: 'TINYINT(1) DEFAULT 0' }, { name: 'last_tested_at', def: 'DATETIME' }, { name: 'last_test_latency_ms', def: 'INT' }, { name: 'created_at', def: 'DATETIME' }, diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index f76a7b2..b68f9fd 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -33,13 +33,11 @@ export async function ensureCourseAttendanceSchema( 'attendance_sessions', ]); const tableNames = new Set(tables.map((table) => table.name)); - const isMySQL = dataSource.options.type === 'mysql'; if (!tableNames.has('attendance_sessions')) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - await runner.query(attendanceSessionsDdl('attendance_sessions', pkDef)); + await runner.query( + attendanceSessionsDdl('attendance_sessions', 'id INTEGER PRIMARY KEY AUTO_INCREMENT'), + ); } if (tableNames.has('class_schedule')) { @@ -72,14 +70,10 @@ export async function ensureCourseAttendanceSchema( } }; await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', + 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', ); await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', + 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', ); }); } @@ -92,12 +86,7 @@ export async function protectAttendanceHistory( const tables = await runner.getTables(['attendance_sessions']); if (tables.length === 0) return; - const isMySQL = dataSource.options.type === 'mysql'; - if (isMySQL) { - await migrateMySQLAttendanceFKs(runner, logger); - } else { - await migrateSQLiteAttendanceFKs(runner, logger); - } + await migrateMySQLAttendanceFKs(runner, logger); }); } @@ -164,97 +153,3 @@ export async function migrateMySQLAttendanceFKs( logger.log(`已添加考勤场次删除保护约束: ${c.name}`); } } - -export async function migrateSQLiteAttendanceFKs( - runner: QueryRunner, - logger: Logger, -): Promise { - // SQLite cannot ALTER TABLE to add foreign keys. - // Rebuild the table inside a transaction: create a new table with FK constraints, - // copy all rows, drop old, rename new, then recreate indexes. - const fkRows: Array<{ id: number }> = await runner.query( - "PRAGMA foreign_key_list('attendance_sessions')", - ); - if (fkRows.length > 0) return; // FKs already present - - logger.log('正在重建 attendance_sessions 表以添加外键保护…'); - - // PRAGMA foreign_keys=OFF must be issued outside the transaction - await runner.query('PRAGMA foreign_keys = OFF'); - try { - await runner.query('BEGIN'); - try { - await runner.query(attendanceSessionsDdl('attendance_sessions_new', 'id INTEGER PRIMARY KEY AUTOINCREMENT')); - await runner.query(` - INSERT INTO attendance_sessions_new ( - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - ) - SELECT - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - FROM attendance_sessions - `); - await runner.query('DROP TABLE attendance_sessions'); - await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - - // Rebuild attendance_records to add/protect FK on attendance_session_id - const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); - const hasSessionFk = recordsFk.some( - (r: { from: string }) => r.from === 'attendance_session_id', - ); - if (!hasSessionFk) { - await runner.query(` - CREATE TABLE attendance_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - schedule_id INTEGER, - attendance_session_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL - ) - `); - await runner.query(` - INSERT INTO attendance_records_new ( - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - ) - SELECT - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - FROM attendance_records - `); - await runner.query('DROP TABLE attendance_records'); - await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } - - // Verify foreign key integrity BEFORE committing the transaction. - // If violations exist, the transaction rolls back and old tables are preserved. - const checkRows = await runner.query('PRAGMA foreign_key_check'); - if (checkRows.length > 0) { - throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); - } - - await runner.query('COMMIT'); - logger.log('attendance_sessions 表外键保护重建完成'); - } catch (err) { - await runner.query('ROLLBACK'); - throw err; - } - } finally { - await runner.query('PRAGMA foreign_keys = ON'); - } -} diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index d5fbf67..be47e00 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -124,7 +124,6 @@ export async function normalizeClassDates( dataSource: DataSource, logger: Logger, ): Promise { - const driver = dataSource.options.type; let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; await withQueryRunner(dataSource, async (runner) => { @@ -135,27 +134,21 @@ export async function normalizeClassDates( // This cleanup is only for legacy schemas that stored dates as strings; // comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE // in strict SQL mode. - if (driver === 'mysql') { - columns = columns.filter((columnName) => { - const column = table.columns.find((item) => item.name === columnName); - const type = String(column?.type ?? '').toLowerCase(); - return !['date', 'datetime', 'timestamp'].includes(type); - }); - if (columns.length === 0) return; - } + columns = columns.filter((columnName) => { + const column = table.columns.find((item) => item.name === columnName); + const type = String(column?.type ?? '').toLowerCase(); + return !['date', 'datetime', 'timestamp'].includes(type); + }); + if (columns.length === 0) return; }); - const columnText = (column: string) => - driver === 'mysql' ? `CAST(${column} AS CHAR)` : column; - const firstTenChars = (column: string) => - driver === 'mysql' - ? `NULLIF(LEFT(${columnText(column)}, 10), '')` - : `NULLIF(substr(${column}, 1, 10), '')`; + const columnText = (column: string) => `CAST(${column} AS CHAR)`; + const firstTenChars = (column: string) => `NULLIF(LEFT(${columnText(column)}, 10), '')`; const normalizedDate = (column: string) => `CASE WHEN ${column} IS NULL THEN NULL ELSE ${firstTenChars(column)} END`; - const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; + const lengthFunction = 'CHAR_LENGTH'; const needsNormalization = (column: string) => `( ${column} IS NOT NULL AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10) diff --git a/apps/server/src/database/database-migrations.class-student.spec.ts b/apps/server/src/database/database-migrations.class-student.spec.ts index 6088762..b07946f 100644 --- a/apps/server/src/database/database-migrations.class-student.spec.ts +++ b/apps/server/src/database/database-migrations.class-student.spec.ts @@ -22,7 +22,7 @@ async function createService(runner: ReturnType) { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.classroom-status.spec.ts b/apps/server/src/database/database-migrations.classroom-status.spec.ts index aaf079e..3702eaf 100644 --- a/apps/server/src/database/database-migrations.classroom-status.spec.ts +++ b/apps/server/src/database/database-migrations.classroom-status.spec.ts @@ -16,7 +16,7 @@ describe('DatabaseMigrationsService — classroom status normalization', () => { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.deposit-refund.spec.ts b/apps/server/src/database/database-migrations.deposit-refund.spec.ts index 1b1ac7c..376de04 100644 --- a/apps/server/src/database/database-migrations.deposit-refund.spec.ts +++ b/apps/server/src/database/database-migrations.deposit-refund.spec.ts @@ -24,7 +24,7 @@ async function createService(runner: ReturnType) { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.room-gender.spec.ts b/apps/server/src/database/database-migrations.room-gender.spec.ts index 3728e3c..12fad00 100644 --- a/apps/server/src/database/database-migrations.room-gender.spec.ts +++ b/apps/server/src/database/database-migrations.room-gender.spec.ts @@ -20,7 +20,7 @@ describe('DatabaseMigrationsService — room gender cleanup', () => { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts index fd8d271..09e9929 100644 --- a/apps/server/src/database/database-migrations.schema.ts +++ b/apps/server/src/database/database-migrations.schema.ts @@ -38,8 +38,7 @@ export async function ensureAttendanceDevicesSchema( dataSource: DataSource, ): Promise { await withQueryRunner(dataSource, async (runner) => { - const isMySQL = dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices ( id ${pk}, device_sn VARCHAR(100) NOT NULL, @@ -77,15 +76,11 @@ export async function ensureAttendanceDevicesSchema( const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique); if (!uniqueSn) { await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)', + 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)', ); } await createIndex( - isMySQL - ? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)' - : 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', + 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', ); }); } @@ -94,8 +89,7 @@ export async function ensureStudentWalletSchema( dataSource: DataSource, ): Promise { await withQueryRunner(dataSource, async (runner) => { - const isMySQL = dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets ( id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -140,9 +134,9 @@ export async function ensureStudentWalletSchema( const hasImportKey = refreshedRoomExpenses?.indices.some((index) => index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); if (!hasImportKey) { - await runner.query(isMySQL - ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); + await runner.query( + 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)', + ); } } const bills = await runner.getTable('bills'); diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index 55a3b5d..c06b78c 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -40,7 +40,7 @@ function mockRunner( return { release, connect, query, getTables, getTable } satisfies MockRunner; } -function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') { +function createDataSource(runner: MockRunner, dbType: string = 'mysql') { return { options: { type: dbType }, createQueryRunner: jest.fn().mockReturnValue(runner), @@ -81,7 +81,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { expect(runner.connect).toHaveBeenCalled(); expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config')); expect(runner.query).toHaveBeenCalledWith( - expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'), + expect.stringContaining('CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)'), ); expect(runner.release).toHaveBeenCalled(); }); @@ -260,7 +260,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => { describe('DatabaseMigrationsService — protectAttendanceHistory', () => { let service: MigrationsPrivate & DatabaseMigrationsService; - async function bootstrap(runner: MockRunner, dbType: string = 'better-sqlite3') { + async function bootstrap(runner: MockRunner, dbType: string = 'mysql') { const dataSource = createDataSource(runner, dbType); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -279,107 +279,6 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { expect(runner.release).toHaveBeenCalled(); }); - it('SQLite: exits early when FKs already exist', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows - await bootstrap(runner); - await service.protectAttendanceHistory(); - - // Should not run any TABLE creation (rebuild) - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0); - expect(runner.release).toHaveBeenCalled(); - }); - - it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - // PRAGMA foreign_key_list for attendance_sessions → empty - runner.query.mockResolvedValueOnce([]); - // PRAGMA foreign_key_list for attendance_records → also empty (no FK yet) - runner.query.mockResolvedValueOnce([]); - await bootstrap(runner); - await service.protectAttendanceHistory(); - - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - - // PRAGMA foreign_keys = OFF outside the transaction - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true); - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe( - true, - ); - expect( - queries.some((q: string) => - q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'), - ), - ).toBe(true); - expect( - queries.some((q: string) => - q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'), - ), - ).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe( - true, - ); - // attendance_records rebuilt with FK - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true); - // PRAGMA foreign_keys restored to ON and foreign_key_check runs - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true); - expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true); - expect(runner.release).toHaveBeenCalled(); - }); - - it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - // Use mockImplementation to match by SQL content, not call position - runner.query.mockImplementation((sql: string) => { - if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) { - return Promise.resolve([]); // FKs absent → trigger rebuild - } - if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) { - return Promise.resolve([ - { table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 }, - ]); - } - return Promise.resolve([]); - }); - await bootstrap(runner); - - await expect(service.protectAttendanceHistory()).rejects.toThrow(/外键一致性检查失败/); - - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - - // The transaction should have been rolled back (ROLLBACK called) - expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true); - // COMMIT should NOT have been called - expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false); - // PRAGMA foreign_keys should still be restored - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true); - expect(runner.release).toHaveBeenCalled(); - }); it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => { const runner = mockRunner({ getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index 4e9da94..f59f649 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -14,12 +14,7 @@ import { config } from 'dotenv'; config(); -const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql'; - export async function runMigrationsOnStartup(): Promise { - // 该迁移由 MySQL 生成;SQLite 开发环境由 AppModule 中的 TypeORM synchronize 建表。 - if (!isMySQL) return; - const ds = new DataSource({ type: 'mysql', host: process.env.DB_HOST || 'localhost', diff --git a/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts b/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts index caeac05..561e962 100644 --- a/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts +++ b/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts @@ -12,10 +12,7 @@ export class EnlargeAiReviewSections1784900000000 implements MigrationInterface const column = table?.columns.find((item) => item.name === 'sections_json'); const columnType = String(column?.type ?? '').toLowerCase(); if (columnType === 'longtext') return; - if (queryRunner.connection.options.type === 'mysql') { - await queryRunner.query('ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT'); - } - // SQLite TEXT 无长度上限,无需变更。 + await queryRunner.query('ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT'); } async down(_queryRunner: QueryRunner): Promise { diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts index 92fc1f2..5298a1c 100644 --- a/apps/server/src/occupancies/occupancies.boundaries.spec.ts +++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts @@ -30,7 +30,7 @@ function createQueryBuilderMock(result: T | null): QueryBuilderMock { function createTransactionDataSource(manager: Record): DataSource { return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, transaction: jest.fn( async (fn: (manager: Record) => unknown) => fn(manager), ), @@ -105,7 +105,7 @@ function createQueryRunnerDataSource(config: { }; return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue({ connect: jest.fn().mockResolvedValue(undefined), startTransaction: jest.fn().mockResolvedValue(undefined), diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 372c435..d5254fd 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -31,7 +31,7 @@ function createQueryBuilderMock(result: T | null): QueryBuilderMock { function createTransactionDataSource(manager: Record): DataSource { return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, transaction: jest.fn(async (fn: (manager: Record) => unknown) => fn(manager)), } as any as DataSource; } diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 6ba2d8e..96717a2 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -163,11 +163,7 @@ export class OccupanciesService { private withPessimisticWriteLock( qb: SelectQueryBuilder, ): SelectQueryBuilder { - const type = this.dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); - } - return qb; + return qb.setLock('pessimistic_write'); } private normalizePositiveMoney(value: number, label: string): number { diff --git a/apps/server/src/occupancies/occupancy-lock.ts b/apps/server/src/occupancies/occupancy-lock.ts index 3e7a16d..c47cd7f 100644 --- a/apps/server/src/occupancies/occupancy-lock.ts +++ b/apps/server/src/occupancies/occupancy-lock.ts @@ -2,11 +2,7 @@ import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm'; export function withPessimisticWriteLock( qb: SelectQueryBuilder, - dataSource: DataSource, + _dataSource: DataSource, ): SelectQueryBuilder { - const type = dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); - } - return qb; + return qb.setLock('pessimistic_write'); } diff --git a/apps/server/src/rooms/room-inspections.service.spec.ts b/apps/server/src/rooms/room-inspections.service.spec.ts index b6d6c82..be420b2 100644 --- a/apps/server/src/rooms/room-inspections.service.spec.ts +++ b/apps/server/src/rooms/room-inspections.service.spec.ts @@ -70,7 +70,7 @@ describe('RoomInspectionsService', () => { }), } as unknown as EntityManager; const dataSource = { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, manager, transaction: jest.fn(async (callback) => callback(manager)), } as unknown as DataSource; diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts index 7bf4d5f..6b8d476 100644 --- a/apps/server/src/rooms/room-inspections.service.ts +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -206,9 +206,7 @@ export class RoomInspectionsService implements OnApplicationBootstrap { allowArchived: boolean, ): Promise { let query = manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId }); - if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { - query = query.setLock('pessimistic_write'); - } + query = query.setLock('pessimistic_write'); const room = await query.getOne(); if (!room) throw new BadRequestException('宿舍不存在'); if (!allowArchived && room.status === 'archived') { diff --git a/apps/server/src/wallets/wallets.service.spec.ts b/apps/server/src/wallets/wallets.service.spec.ts index adf919f..740aac2 100644 --- a/apps/server/src/wallets/wallets.service.spec.ts +++ b/apps/server/src/wallets/wallets.service.spec.ts @@ -138,22 +138,6 @@ describe('WalletsService wallet locking', () => { }; }; - it('skips pessimistic locking for SQLite', async () => { - const ctx = createQueryManager(); - const service = new WalletsService( - {} as any, - {} as any, - {} as any, - { options: { type: 'better-sqlite3' } } as any, - ); - - const result = await (service as any).getOrCreateWallet(ctx.manager, 10, true); - - expect(result).toBe(ctx.wallet); - expect(ctx.query.setLock).not.toHaveBeenCalled(); - expect(ctx.query.getOne).toHaveBeenCalled(); - }); - it('keeps pessimistic write locking for MySQL', async () => { const ctx = createQueryManager(); const service = new WalletsService( diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 62a078e..009ae64 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -284,9 +284,7 @@ export class WalletsService { let query = manager .createQueryBuilder(StudentWallet, 'wallet') .where('wallet.studentId = :studentId', { studentId }); - if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { - query = query.setLock('pessimistic_write'); - } + query = query.setLock('pessimistic_write'); return query.getOne(); }; let wallet = await find(); diff --git a/package-lock.json b/package-lock.json index d061824..9bc190e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,7 +132,6 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -148,9 +147,6 @@ "tsconfig-paths": "^4.2.0", "typescript": "~6.0.2", "typescript-eslint": "^8.20.0" - }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0" } }, "node_modules/@angular-devkit/core": { @@ -5865,16 +5861,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -8290,6 +8276,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -8326,6 +8313,7 @@ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -8745,7 +8733,8 @@ "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/chrome-trace-event": { "version": "1.0.4", @@ -9923,6 +9912,7 @@ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -9953,6 +9943,7 @@ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -10853,6 +10844,7 @@ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -11049,7 +11041,8 @@ "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/finalhandler": { "version": "2.1.1", @@ -11531,7 +11524,8 @@ "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/glob": { "version": "13.0.6", @@ -12042,7 +12036,8 @@ "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/inline-style-parser": { "version": "0.2.7", @@ -14640,6 +14635,7 @@ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -14869,7 +14865,8 @@ "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/mrmime": { "version": "2.0.1", @@ -15017,7 +15014,8 @@ "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/napi-postinstall": { "version": "0.3.4", @@ -15079,6 +15077,7 @@ "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -15092,6 +15091,7 @@ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -15973,6 +15973,7 @@ "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -16102,6 +16103,7 @@ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -16190,6 +16192,7 @@ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "optional": true, + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -16220,6 +16223,7 @@ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17026,7 +17030,8 @@ } ], "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -17048,6 +17053,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -17555,6 +17561,7 @@ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -18216,6 +18223,7 @@ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, diff --git a/package.json b/package.json index 1ec0d36..be80561 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "turbo run test", "format": "turbo run format", "typecheck": "turbo run typecheck", - "clean": "rimraf apps/server/dist apps/admin/dist apps/server/dorm_billing.db node_modules apps/*/node_modules packages/*/node_modules" + "clean": "rimraf apps/server/dist apps/admin/dist node_modules apps/*/node_modules packages/*/node_modules" }, "devDependencies": { "oxfmt": "^0.57.0", diff --git a/技术文档.md b/技术文档.md index f2ea5f0..8d61ff7 100644 --- a/技术文档.md +++ b/技术文档.md @@ -26,10 +26,10 @@ ``` 前端 (React + Vite) 后端 (NestJS) 数据库 ┌─────────────────┐ ┌──────────────────┐ ┌──────────┐ -│ React 19 │ │ NestJS 11 │ │ SQLite │ -│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ (开发) │ -│ ECharts │ │ JWT + Passport │ │ MySQL 8 │ -│ Vite 8 │ │ ExcelJS + PDFKit │ │ (生产) │ +│ React 19 │ │ NestJS 11 │ │ MySQL 8 │ +│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ │ +│ ECharts │ │ JWT + Passport │ │ │ +│ Vite 8 │ │ ExcelJS + PDFKit │ │ │ └─────────────────┘ └──────────────────┘ └──────────┘ ``` @@ -51,7 +51,7 @@ - class-validator(参数校验) - ExcelJS(Excel 导出) - PDFKit(PDF 导出) -- SQLite / MySQL(双数据库支持) +- MySQL 8(唯一支持的数据库) --- @@ -199,7 +199,7 @@ | 变量 | 说明 | 默认值 | |------|------|--------| -| DB_TYPE | 数据库类型 | sqlite | +| DB_TYPE | 数据库类型(仅支持 MySQL) | mysql | | DB_HOST | MySQL 主机 | localhost | | DB_PORT | MySQL 端口 | 3306 | | DB_USERNAME | 数据库用户 | root |