refactor: 移除 SQLite 支持,仅保留 MySQL

This commit is contained in:
2026-08-05 17:43:13 +08:00
parent 81e622fa09
commit 47720a8fcc
35 changed files with 111 additions and 1961 deletions

View File

@@ -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` |

View File

@@ -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')],
});

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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([]);
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -78,8 +78,7 @@ export class AiConfigService implements AiConfigProbeContext {
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
const errno = isErrWithCode ? (err as Record<string, unknown>).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;
}

View File

@@ -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<number>('DB_PORT', 3306),
username: config.get('DB_USERNAME', 'root'),
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('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<string>('DB_DATABASE', 'dorm_billing.db'),
migrations: allMigrations,
type: 'mysql' as const,
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306),
username: config.get('DB_USERNAME', 'root'),
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
entities: allEntities,
migrations: allMigrations,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
charset: 'utf8mb4',
};
},
}),

View File

@@ -244,8 +244,8 @@ export class AttendanceLessonService {
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).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 },
});

View File

@@ -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<Repository<Role>>(getRepositoryToken(Role));
const userRepo = app.get<Repository<User>>(getRepositoryToken(User));
const studentRepo = app.get<Repository<Student>>(getRepositoryToken(Student));
const mappingRepo = app.get<Repository<StudentDingMapping>>(
getRepositoryToken(StudentDingMapping),
);
const organizationRepo = app.get<Repository<Organization>>(getRepositoryToken(Organization));
const attendanceRepo = app.get<Repository<AttendanceRecord>>(
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<Pick<AttendanceRecord, 'studentId' | 'status' | 'source'>> =
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<AttendanceRecord, 'studentId'>, right: Pick<AttendanceRecord, 'studentId'>) =>
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' }),
]);
});
});

View File

@@ -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: '张三' } }]);

View File

@@ -1,380 +0,0 @@
import Database from 'better-sqlite3';
type SqliteDB = InstanceType<typeof Database>;
/**
* 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<string, unknown>;
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<string, unknown>;
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);
});
});

View File

@@ -8,14 +8,11 @@ export async function ensureAiConfigTable(
): Promise<void> {
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' },

View File

@@ -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<void> {
// 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');
}
}

View File

@@ -124,7 +124,6 @@ export async function normalizeClassDates(
dataSource: DataSource,
logger: Logger,
): Promise<void> {
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)

View File

@@ -22,7 +22,7 @@ async function createService(runner: ReturnType<typeof createRunner>) {
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
options: { type: 'mysql' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},

View File

@@ -16,7 +16,7 @@ describe('DatabaseMigrationsService — classroom status normalization', () => {
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
options: { type: 'mysql' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},

View File

@@ -24,7 +24,7 @@ async function createService(runner: ReturnType<typeof createRunner>) {
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
options: { type: 'mysql' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},

View File

@@ -20,7 +20,7 @@ describe('DatabaseMigrationsService — room gender cleanup', () => {
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
options: { type: 'mysql' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},

View File

@@ -38,8 +38,7 @@ export async function ensureAttendanceDevicesSchema(
dataSource: DataSource,
): Promise<void> {
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<void> {
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');

View File

@@ -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' }] }],

View File

@@ -14,12 +14,7 @@ import { config } from 'dotenv';
config();
const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql';
export async function runMigrationsOnStartup(): Promise<void> {
// 该迁移由 MySQL 生成SQLite 开发环境由 AppModule 中的 TypeORM synchronize 建表。
if (!isMySQL) return;
const ds = new DataSource({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',

View File

@@ -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<void> {

View File

@@ -30,7 +30,7 @@ function createQueryBuilderMock<T>(result: T | null): QueryBuilderMock<T> {
function createTransactionDataSource(manager: Record<string, unknown>): DataSource {
return {
options: { type: 'sqlite' },
options: { type: 'mysql' },
transaction: jest.fn(
async (fn: (manager: Record<string, unknown>) => 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),

View File

@@ -31,7 +31,7 @@ function createQueryBuilderMock<T>(result: T | null): QueryBuilderMock<T> {
function createTransactionDataSource(manager: Record<string, unknown>): DataSource {
return {
options: { type: 'sqlite' },
options: { type: 'mysql' },
transaction: jest.fn(async (fn: (manager: Record<string, unknown>) => unknown) => fn(manager)),
} as any as DataSource;
}

View File

@@ -163,11 +163,7 @@ export class OccupanciesService {
private withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> {
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 {

View File

@@ -2,11 +2,7 @@ import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm';
export function withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
dataSource: DataSource,
_dataSource: DataSource,
): SelectQueryBuilder<T> {
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');
}

View File

@@ -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;

View File

@@ -206,9 +206,7 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
allowArchived: boolean,
): Promise<Room> {
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') {

View File

@@ -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(

View File

@@ -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();

50
package-lock.json generated
View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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参数校验
- ExcelJSExcel 导出)
- PDFKitPDF 导出)
- 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 |