refactor: 移除 SQLite 支持,仅保留 MySQL
This commit is contained in:
11
README.md
11
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` |
|
||||
|
||||
@@ -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')],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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: '张三' } }]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('DatabaseMigrationsService — classroom status normalization', () => {
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
options: { type: 'better-sqlite3' },
|
||||
options: { type: 'mysql' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('DatabaseMigrationsService — room gender cleanup', () => {
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
options: { type: 'better-sqlite3' },
|
||||
options: { type: 'mysql' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' }] }],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
50
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
技术文档.md
12
技术文档.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 |
|
||||
|
||||
Reference in New Issue
Block a user