fix(correctness): 并发/事务/实体/时区/状态一致性修复

由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
2026-08-09 21:29:54 +08:00
parent 99ea931409
commit f50301148d
54 changed files with 3843 additions and 722 deletions

View File

@@ -9,12 +9,11 @@ export class AgentToolRegistry {
/** Register a tool (called once at module init). */
register(tool: ToolDef): void {
const idx = this.tools.findIndex((t) => t.name === tool.name);
if (idx >= 0) {
this.tools[idx] = tool;
} else {
this.tools.push(tool);
if (this.tools.some((t) => t.name === tool.name)) {
// 重复 key 会在运行时造成工具覆盖/歧义,注册阶段直接失败更明确。
throw new Error(`Agent tool 名称重复: ${tool.name}`);
}
this.tools.push(tool);
}
/**

View File

@@ -55,6 +55,44 @@ describe('A2uiSubmissionsService', () => {
expect(repo.save).not.toHaveBeenCalled();
});
it('并发重复提交遇到唯一约束冲突时返回既有记录而不是抛 500', async () => {
const dupError = Object.assign(new Error('Duplicate entry'), {
code: 'ER_DUP_ENTRY',
errno: 1062,
});
const { service } = createService({
findOne: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(submission),
save: jest.fn().mockRejectedValue(dupError),
});
const result = await service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: '{"ok":false}',
});
expect(result.created).toBe(false);
expect(result.submission).toEqual(submission);
});
it('非唯一约束错误仍然原样抛给调用方', async () => {
const dbError = new Error('db down');
const { service } = createService({
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockRejectedValue(dbError),
});
await expect(
service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: submission.resultJson,
}),
).rejects.toBe(dbError);
});
it('findSubmission 透传查询条件', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(submission),

View File

@@ -10,6 +10,18 @@ export interface RecordSubmissionInput {
resultJson?: string | null;
}
/** 判断是否为唯一约束冲突MySQL ER_DUP_ENTRY / PostgreSQL 23505 */
const isDuplicateKeyError = (error: unknown): boolean => {
const err = error as {
code?: string;
errno?: number;
driverError?: { code?: string; errno?: number };
};
const code = err.driverError?.code ?? err.code;
const errno = err.driverError?.errno ?? err.errno;
return code === 'ER_DUP_ENTRY' || code === '23505' || errno === 1062;
};
/**
* A2UI 提交幂等服务。
*/
@@ -26,15 +38,25 @@ export class A2uiSubmissionsService {
}> {
const existing = await this.findSubmission(input.artifactId, input.clientRequestId);
if (existing) return { created: false, submission: existing };
const submission = await this.submissions.save(
this.submissions.create({
artifactId: input.artifactId,
clientRequestId: input.clientRequestId,
status: input.status,
resultJson: input.resultJson ?? null,
}),
);
return { created: true, submission };
try {
const submission = await this.submissions.save(
this.submissions.create({
artifactId: input.artifactId,
clientRequestId: input.clientRequestId,
status: input.status,
resultJson: input.resultJson ?? null,
}),
);
return { created: true, submission };
} catch (error) {
// 并发下两个请求同时通过 findSubmission 时,唯一索引 uk_ai_a2ui_submissions_artifact_client
// 保证只有一个插入成功;另一个捕获唯一约束冲突后返回既有结果,而不是抛 500
if (isDuplicateKeyError(error)) {
const concurrent = await this.findSubmission(input.artifactId, input.clientRequestId);
if (concurrent) return { created: false, submission: concurrent };
}
throw error;
}
}
async findSubmission(

View File

@@ -165,7 +165,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
entities: allEntities,
migrations: allMigrations,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
// 生产安全:仅当显式 DB_SYNCHRONIZE=true 时才自动同步表结构
synchronize: config.get('DB_SYNCHRONIZE') === 'true',
charset: 'utf8mb4',
};
},

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { getWeekDayFromDateOnly } from '../common/china-time';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { AttendanceRecord, ClassSchedule } from '../entities';
@@ -31,8 +32,7 @@ export class AttendanceCalendarService {
}
private getWeekDayForDate(date: string): number {
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
return day === 0 ? 7 : day;
return getWeekDayFromDateOnly(date);
}
async getScheduleOptionsForAttendance(classId: number, date: string) {

View File

@@ -1,3 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { AttendanceGenerationService } from './attendance-generation.service';
describe('AttendanceGenerationService — 默认周范围', () => {
@@ -62,4 +63,94 @@ describe('AttendanceGenerationService — 默认周范围', () => {
dateTo: '2026-08-31',
});
});
it('rejects invalid date formats before generating', async () => {
const service = createService();
await expect(
service.generateAttendanceFromSchedules({
classId: 1,
dateFrom: '2026/08/01',
dateTo: '2026-08-07',
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects the 9999-12-31 boundary that would overflow date iteration', async () => {
const service = createService();
await expect(
service.generateAttendanceFromSchedules({
classId: 1,
dateFrom: '2026-08-01',
dateTo: '9999-12-31',
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('default afternoon period is labeled 下午', async () => {
const saved: Array<Record<string, unknown>> = [];
const periodConfigRepo = {
count: jest.fn().mockResolvedValue(0),
save: jest.fn(async (entities: Array<Record<string, unknown>>) => {
saved.push(...entities);
return entities;
}),
create: jest.fn((data: Record<string, unknown>) => ({ ...data })),
find: jest.fn().mockResolvedValue([]),
};
const service = new AttendanceGenerationService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
periodConfigRepo as never,
{} as never,
);
await service.getAttendancePeriodConfigs();
expect(saved.find((period) => period.periodKey === 'afternoon')).toEqual(
expect.objectContaining({ label: '下午', startTime: '14:00', endTime: '17:00' }),
);
});
it('wraps clear + save of period configs in a single transaction', async () => {
const manager = {
clear: jest.fn().mockResolvedValue(undefined),
create: jest.fn((_entity: unknown, value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const periodConfigRepo = {
count: jest.fn().mockResolvedValue(1),
clear: jest.fn(),
save: jest.fn(),
find: jest.fn().mockResolvedValue([]),
};
const service = new AttendanceGenerationService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
periodConfigRepo as never,
dataSource as never,
);
await service.saveAttendancePeriodConfigs({
periods: [
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
{ periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true },
],
} as never);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.clear).toHaveBeenCalledTimes(1);
expect(manager.save).toHaveBeenCalledWith(expect.any(Array));
// 两段写都在事务内完成,不再直接走 repo 的 clear/save
expect(periodConfigRepo.clear).not.toHaveBeenCalled();
expect(periodConfigRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -13,13 +13,14 @@ import {
import { toMinutes, isClassStudentActiveOnDate } from './attendance-time';
import dayjs from '../common/dayjs';
import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto';
import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time';
@Injectable()
export class AttendanceGenerationService {
private readonly defaultAttendancePeriods = [
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
{ periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
] as const;
@@ -62,6 +63,13 @@ export class AttendanceGenerationService {
if (dateFrom > dateTo) {
throw new BadRequestException('dateFrom must not be later than dateTo');
}
// 迭代前校验日期格式与范围非法格式直接拒绝9999-12-31 继续 +1 天会溢出归一化
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
throw new BadRequestException('无效日期');
}
if (dateFrom > '9999-12-30' || dateTo > '9999-12-30') {
throw new BadRequestException('日期超出合理范围(最大支持 9999-12-30');
}
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) {
@@ -94,17 +102,19 @@ export class AttendanceGenerationService {
existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
);
// 考勤时段配置只在循环外取一次,避免每 (date, schedule) 都触发 count+find 查询N+1
const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs();
const entities: AttendanceRecord[] = [];
const end = new Date(dateTo);
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD');
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
let dateStr = dateFrom;
while (dateStr <= dateTo) {
const weekDay = getWeekDayFromDateOnly(dateStr);
for (const sched of schedules) {
if (sched.weekDay !== weekDay) continue;
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
const session = await this.mapScheduleTimeToSession(sched.startTime);
const session = await this.mapScheduleTimeToSession(sched.startTime, attendancePeriodConfigs);
const classStudentsForDate = classStudents.filter((cs) =>
isClassStudentActiveOnDate(cs, dateStr),
);
@@ -124,6 +134,7 @@ export class AttendanceGenerationService {
existingKeys.add(key);
}
}
dateStr = addDaysToDateOnly(dateStr, 1);
}
const saved = await this.attendanceRepo.save(entities);
@@ -152,23 +163,6 @@ export class AttendanceGenerationService {
});
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private getCourseClock(date: Date): { date: string; minutes: number } {
const c = dayjs(date).utcOffset(8);
return {
date: c.format('YYYY-MM-DD'),
minutes: c.hour() * 60 + c.minute(),
};
}
private shiftDate(date: string, days: number): string {
return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD');
}
private async ensureAttendancePeriodConfigs() {
const count = await this.attendancePeriodConfigRepo.count();
if (count === 0) {
@@ -187,9 +181,8 @@ export class AttendanceGenerationService {
}
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
const parsedDate = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('无效日期');
const weekDay = getWeekDayFromDateOnly(date);
const qb = this.scheduleRepo
.createQueryBuilder('schedule')
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
@@ -209,9 +202,11 @@ export class AttendanceGenerationService {
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
if (!session) return schedules;
// 配置只取一次并传给每次 mapScheduleTimeToSession避免循环内重复 count+find 查询
const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs();
const matchedSchedules: ClassSchedule[] = [];
for (const schedule of schedules) {
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
if ((await this.mapScheduleTimeToSession(schedule.startTime, attendancePeriodConfigs)) === session) {
matchedSchedules.push(schedule);
}
}
@@ -251,30 +246,39 @@ export class AttendanceGenerationService {
}
}
await this.attendancePeriodConfigRepo.clear();
await this.attendancePeriodConfigRepo.save(
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
);
// clear + save 两段写放进同一事务:中途失败整体回滚,避免出现空配置表
await this.dataSource.transaction(async (manager) => {
await manager.clear(AttendancePeriodConfig);
await manager.save(
manager.create(
AttendancePeriodConfig,
normalized.map((period) => ({ ...period })),
),
);
});
return this.getAttendancePeriodConfigs();
}
async resetAttendancePeriodConfigs() {
await this.attendancePeriodConfigRepo.clear();
return this.ensureAttendancePeriodConfigs();
// clear + 写入默认配置放进同一事务:失败整体回滚,避免残留空配置表
await this.dataSource.transaction(async (manager) => {
await manager.clear(AttendancePeriodConfig);
await manager.save(
manager.create(
AttendancePeriodConfig,
this.defaultAttendancePeriods.map((period) => ({ ...period, enabled: true })),
),
);
});
return this.getAttendancePeriodConfigs();
}
private mapLessonScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
if (hour < 12) return 'morning';
if (hour < 17) return 'afternoon';
if (hour < 20) return 'evening_study';
return 'night_check';
}
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
private async mapScheduleTimeToSession(
startTime: string,
configs?: AttendancePeriodConfig[],
): Promise<string> {
const startMinutes = toMinutes(startTime);
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
const periods = (configs ?? (await this.ensureAttendancePeriodConfigs())).filter((period) => period.enabled);
const matched = periods.find((period) => {
const periodStart = toMinutes(period.startTime);
const periodEnd = toMinutes(period.endTime);

View File

@@ -9,7 +9,7 @@ describe('AttendanceImportService', () => {
findOne: jest.fn(),
save: jest.fn(),
};
const studentRepo = { findOne: jest.fn() };
const studentRepo = { findOne: jest.fn(), find: jest.fn() };
const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
const dingTalkService = {
fetchAttendanceResults: jest.fn(),
@@ -17,17 +17,31 @@ describe('AttendanceImportService', () => {
const attendanceService = {
autoMatchDingRecords: jest.fn(),
};
let runner: {
connect: jest.Mock;
query: jest.Mock;
release: jest.Mock;
};
const dataSource = {
createQueryRunner: jest.fn(() => runner),
};
let service: AttendanceImportService;
beforeEach(() => {
jest.clearAllMocks();
runner = {
connect: jest.fn().mockResolvedValue(undefined),
query: jest.fn().mockResolvedValue([{ acquired: 1 }]),
release: jest.fn().mockResolvedValue(undefined),
};
service = new AttendanceImportService(
dingRawRepo as never,
studentRepo as never,
studentDingMappingRepo as never,
dingTalkService as unknown as DingTalkService,
attendanceService as unknown as AttendanceService,
dataSource as never,
);
});
@@ -118,6 +132,55 @@ describe('AttendanceImportService', () => {
);
});
it('writes checkInTime for OnDuty and checkOutTime for OffDuty only', async () => {
const onDuty = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-on',
checkType: 'OnDuty',
});
const offDuty = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T18:00:00.000Z',
checkId: 'check-off',
checkType: 'OffDuty',
});
expect(onDuty.checkInTime).toEqual(new Date('2026-07-01T08:00:00.000Z'));
expect(onDuty.checkOutTime).toBeUndefined();
expect(offDuty.checkOutTime).toEqual(new Date('2026-07-01T18:00:00.000Z'));
expect(offDuty.checkInTime).toBeUndefined();
});
it('does not write checkIn/checkOut times for a non-whitelisted checkType', async () => {
const entity = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-unknown',
checkType: 'OnDuty/OffDuty',
});
// 非法考勤类型不写任何时间,避免污染 checkOutTime/checkInTime记录本身仍保留
expect(entity.attendanceType).toBe('OnDuty/OffDuty');
expect(entity.checkInTime).toBeUndefined();
expect(entity.checkOutTime).toBeUndefined();
});
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
@@ -133,8 +196,8 @@ describe('AttendanceImportService', () => {
},
]);
dingRawRepo.find.mockResolvedValue([]);
studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 });
studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' });
studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]);
studentRepo.find.mockResolvedValue([{ id: 3, name: '张三' }]);
dingRawRepo.save.mockImplementation(async (entities) => entities);
await service.importFromDingTalk({
@@ -148,6 +211,11 @@ describe('AttendanceImportService', () => {
[expect.objectContaining({ userName: '张三' })],
{ chunk: 50 },
);
// 批量预取:只查一次映射 + 一次学生,不再逐条 findOne
expect(studentDingMappingRepo.find).toHaveBeenCalledTimes(1);
expect(studentRepo.find).toHaveBeenCalledTimes(1);
expect(studentDingMappingRepo.findOne).not.toHaveBeenCalled();
expect(studentRepo.findOne).not.toHaveBeenCalled();
});
@@ -305,4 +373,193 @@ describe('AttendanceImportService', () => {
}
});
it('returns success false when a batch save fails', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-save-fail',
checkType: 'OnDuty',
},
]);
dingRawRepo.find.mockResolvedValue([]);
dingRawRepo.save.mockRejectedValue(new Error('database down'));
const result = await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
});
expect(result.success).toBe(false);
expect(result.imported).toBe(0);
expect(result.errors.some((message) => message.includes('Batch save error'))).toBe(true);
expect(service.running).toBe(false);
});
it('cleans up isRunning and importingUserId even when runner.release fails', async () => {
runner.release.mockRejectedValueOnce(new Error('release boom'));
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
const result = await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
userId: 7,
});
expect(result.success).toBe(true);
expect(service.running).toBe(false);
expect((service as any).importingUserId).toBeUndefined();
expect(runner.release).toHaveBeenCalled();
});
it('releases the query runner when connect fails to avoid pool leaks', async () => {
runner.connect.mockRejectedValueOnce(new Error('connect boom'));
await expect(
service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
}),
).rejects.toThrow('connect boom');
expect(runner.release).toHaveBeenCalledTimes(1);
expect(runner.query).not.toHaveBeenCalled();
expect(service.running).toBe(false);
});
it('watchdog only warns and never releases the DB lock while the import is running', async () => {
jest.useFakeTimers();
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
try {
let releaseImport: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
releaseImport = resolve;
});
dingTalkService.fetchAttendanceResults.mockImplementation(() => gate.then(() => []));
const importPromise = service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
});
await jest.advanceTimersByTimeAsync(30 * 60 * 1000);
// 看门狗只告警,不执行 RELEASE_LOCK锁仍由导入结束时释放
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('30 分钟'));
expect(
runner.query.mock.calls.some(([sql]) => String(sql).includes('RELEASE_LOCK')),
).toBe(false);
releaseImport!();
await importPromise;
} finally {
warnSpy.mockRestore();
jest.useRealTimers();
}
});
it('fetches user×date batches concurrently with a bounded concurrency of 4', async () => {
const userIds = Array.from({ length: 101 }, (_, index) => `user-${index + 1}`); // 3 user batches
// 3 user batches × 2 date ranges = 6 requests
let inFlight = 0;
let maxInFlight = 0;
dingTalkService.fetchAttendanceResults.mockImplementation(async (params) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 10));
inFlight -= 1;
return [{
userId: params.userIds[0],
userName: '',
workDate: params.startDate,
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '',
checkId: `${params.startDate}-${params.userIds[0]}`,
checkType: 'OnDuty',
}];
});
const results = await (service as any).fetchAllPages({
startDate: '2026-07-01',
endDate: '2026-07-10',
userIds,
});
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(6);
expect(maxInFlight).toBeGreaterThan(1);
expect(maxInFlight).toBeLessThanOrEqual(4);
expect(results).toHaveLength(6);
// 结果顺序仍为(日期范围 × 用户批次)的原始顺序
expect(results[0].checkId).toBe('2026-07-01-user-1');
expect(results[1].checkId).toBe('2026-07-01-user-51');
expect(results[3].checkId).toBe('2026-07-08-user-1');
expect(results[4].checkId).toBe('2026-07-08-user-51');
expect(results[5].checkId).toBe('2026-07-08-user-101');
});
it('chunks dingId dedup lookups into blocks of at most 1000 and merges results', async () => {
const checkIds = Array.from({ length: 1001 }, (_, index) => `check-${index + 1}`);
// 输入含重复 id先去重再分块返回 Map 仍按 dingId 去重
const results = [...checkIds, 'check-1', 'check-500'].map((checkId) => ({ checkId }));
const inValues = (value: unknown): string[] => {
if (Array.isArray(value)) return value as string[];
if (value && typeof value === 'object' && '_value' in value) {
return (value as { _value: unknown })._value as string[];
}
return [];
};
dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) =>
inValues(where.dingId).map((dingId) => ({ dingId })),
);
const map = await (service as any).getExistingRecordsByDingId(results);
// 1001 个去重后的 id 被切成 1000 + 1 两块并发查询
expect(dingRawRepo.find).toHaveBeenCalledTimes(2);
const firstChunk = inValues(
(dingRawRepo.find.mock.calls[0]?.[0] as { where: { dingId: unknown } }).where.dingId,
);
const secondChunk = inValues(
(dingRawRepo.find.mock.calls[1]?.[0] as { where: { dingId: unknown } }).where.dingId,
);
expect(firstChunk).toHaveLength(1000);
expect(secondChunk).toHaveLength(1);
// 分块结果合并后仍能命中所有 id且无重复
expect(map.size).toBe(1001);
expect(map.get('check-1')?.dingId).toBe('check-1');
expect(map.get('check-1001')?.dingId).toBe('check-1001');
});
it('keeps a single query at the 1000-id chunk boundary', async () => {
const checkIds = Array.from({ length: 1000 }, (_, index) => `check-${index + 1}`);
const inValues = (value: unknown): string[] => {
if (Array.isArray(value)) return value as string[];
if (value && typeof value === 'object' && '_value' in value) {
return (value as { _value: unknown })._value as string[];
}
return [];
};
dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) =>
inValues(where.dingId).map((dingId) => ({ dingId })),
);
const map = await (service as any).getExistingRecordsByDingId(
checkIds.map((checkId) => ({ checkId })),
);
expect(dingRawRepo.find).toHaveBeenCalledTimes(1);
expect(map.size).toBe(1000);
});
});

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { DataSource, Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs';
import dayjs from '../common/dayjs';
import {
@@ -12,6 +12,19 @@ import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingta
import { AttendanceService } from './attendance.service';
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
/** 考勤导入 DB 互斥锁名GET_LOCK/RELEASE_LOCK 共用) */
const ATTENDANCE_IMPORT_LOCK = 'gongxue:attendance-import';
/**
* 钉钉考勤类型白名单(与现有 attendance_type 取值一致)。
* 只有 OnDuty/OffDuty 能区分签到/签退,才允许写入 checkInTime/checkOutTime
* 其他类型(如 OnDuty/OffDuty、NotSigned 等)只保留原始值,不写打卡时间。
*/
const ATTENDANCE_CHECK_TYPE_WHITELIST = ['OnDuty', 'OffDuty'];
/** 单次 IN(dingIds) 查询最多携带的 id 数:超过该上限按块并发查询,避免无界 IN 列表。 */
const MAX_DING_IDS_PER_QUERY = 1000;
/**
* Service for importing DingTalk attendance data into the system.
*
@@ -26,7 +39,12 @@ import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dt
export class AttendanceImportService {
private readonly logger = new Logger(AttendanceImportService.name);
/** RxJS Subject emitting live progress during import */
/**
* RxJS Subject emitting live progress during import。
* 注意:这是进程内存态——多实例部署时每个实例各持有一份 Subject
* SSE 只会收到触发导入的那个实例发出的事件(多实例下进度可能不准,如需准确
* 应改用 Redis pub/sub 等共享通道,本轮不强改)。
*/
private progressSubject = new Subject<ImportProgressEvent>();
private isRunning = false;
/** ID of the user who triggered the current import (for SSE scoping) */
@@ -40,6 +58,7 @@ export class AttendanceImportService {
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
private readonly dingTalkService: DingTalkService,
private readonly attendanceService: AttendanceService,
private readonly dataSource: DataSource,
) {}
/**
@@ -77,24 +96,81 @@ export class AttendanceImportService {
throw new Error('An import is already in progress');
}
const startedAt = Date.now();
this.isRunning = true;
this.importingUserId = params.userId;
// Safety timeout: auto-reset isRunning after 30 minutes in case of
// an unhandled exception that bypasses the finally block (extremely rare).
const SAFETY_TIMEOUT_MS = 30 * 60 * 1000;
const safetyTimer = setTimeout(() => {
if (this.isRunning) {
this.logger.error('Import safety timeout triggered — force-resetting isRunning');
this.isRunning = false;
// DB 层互斥:用 MySQL advisory lockGET_LOCK保证同一时刻只有一个导入在跑
// 不再依赖内存计时器(计时器超时可能把仍在运行的导入误判为可再次启动)。
// 锁绑定在同一个 query runner 连接上;进程异常退出时连接关闭会自动释放锁。
const runner = this.dataSource.createQueryRunner();
let lockAcquired = false;
let watchdog: NodeJS.Timeout | undefined;
try {
// connect 也放进 try/finally连接失败时同样 release runner避免连接池泄漏
await runner.connect();
const lockRows = (await runner.query(
`SELECT GET_LOCK('${ATTENDANCE_IMPORT_LOCK}', 0) AS acquired`,
)) as Array<{ acquired: number | string }> | undefined;
lockAcquired =
Array.isArray(lockRows) &&
lockRows.length > 0 &&
Number((lockRows[0] as { acquired?: unknown })?.acquired) === 1;
if (!lockAcquired) {
throw new Error('An import is already in progress');
}
}, SAFETY_TIMEOUT_MS);
this.isRunning = true;
this.importingUserId = params.userId;
// 兜底看门狗:只告警、不释放锁。若导入仍在运行就主动 RELEASE_LOCK
// 会让另一个实例误以为锁空闲而并发导入,破坏多实例互斥。
// 锁只由 finally 中的 RELEASE_LOCK 释放进程异常退出时连接关闭MySQL 会自动释放锁。
watchdog = setTimeout(() => {
this.logger.warn(
'考勤导入超过 30 分钟仍未完成,请检查任务是否卡死(锁会在导入结束或进程退出时自动释放)',
);
}, 30 * 60 * 1000);
return await this.runImport(params);
} finally {
if (watchdog) clearTimeout(watchdog);
if (lockAcquired) {
try {
await runner.query(`SELECT RELEASE_LOCK('${ATTENDANCE_IMPORT_LOCK}')`);
} catch (releaseError) {
this.logger.warn(
`Failed to release attendance import DB lock: ${String(releaseError)}`,
);
}
}
// release 失败只告警,不影响 isRunning/importingUserId 的清理
try {
await runner.release();
} catch (releaseError) {
this.logger.warn(
`Failed to release attendance import query runner: ${String(releaseError)}`,
);
}
// 只有成功抢到锁的调用才需要清理运行态;未抢到锁时这些字段从未被设置
if (lockAcquired) {
this.isRunning = false;
this.importingUserId = undefined;
}
}
}
/**
* The actual import pipeline (fetch → parse → deduplicate → save → auto-match).
* Runs while the caller holds the DB-level attendance-import lock.
*/
private async runImport(params: {
startDate: string;
endDate: string;
userIds?: string[];
autoMatch?: boolean;
userId?: number;
}): Promise<ImportResult> {
const startedAt = Date.now();
const errors: string[] = [];
let imported = 0;
let skipped = 0;
let matched = 0;
// 保存阶段失败的批次数:只要存在保存失败,整体结果即为失败
let failedSaves = 0;
try {
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
@@ -117,10 +193,12 @@ export class AttendanceImportService {
}
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
// 批量预取 dingUserId → 学生姓名(映射+学生各查一次),避免逐条 mapToEntity 时 N+1
const studentNameByDingUserId = await this.buildStudentNameByDingUserId(newRecords);
const batchSize = 100;
for (let i = 0; i < newRecords.length; i += batchSize) {
const batch = newRecords.slice(i, i + batchSize);
const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
const entities = batch.map((record) => this.mapToEntity(record, studentNameByDingUserId));
try {
await this.dingRawRepo.save(entities, { chunk: 50 });
imported += entities.length;
@@ -128,6 +206,7 @@ export class AttendanceImportService {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`Batch save error at offset ${i}: ${msg}`);
failedSaves += 1;
this.logger.error(`Batch save error: ${msg}`);
}
}
@@ -140,18 +219,14 @@ export class AttendanceImportService {
const duration = Date.now() - startedAt;
this.emit('complete', imported, rawResults.length, `Import complete: ${imported} new, ${skipped} skipped, ${matched} matched (${duration}ms)`);
this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched`);
return { success: true, imported, skipped, matched, errors, duration };
this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched, ${failedSaves} failed batches`);
return { success: failedSaves === 0, imported, skipped, matched, errors, duration };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
this.emit('error', imported, 0, `Import failed: ${msg}`, msg);
this.logger.error(`DingTalk attendance import failed: ${msg}`);
return { success: false, imported, skipped, matched, errors, duration: Date.now() - startedAt };
} finally {
clearTimeout(safetyTimer);
this.isRunning = false;
this.importingUserId = undefined;
}
}
@@ -177,24 +252,34 @@ export class AttendanceImportService {
const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
const totalRequests = userBatches.length * dateRanges.length;
let completedRequests = 0;
let fetchedCount = 0;
for (const range of dateRanges) {
for (const users of userBatches) {
// 用户×日期批次相互独立,按并发上限 4 批量 Promise.all 拉取;
// 结果仍按(日期范围 × 用户批次)的原始顺序收集,保持返回结构不变。
const batches = dateRanges.flatMap((range) =>
userBatches.map((users) => ({ range, users })),
);
const results = await this.mapWithConcurrency(
batches,
4,
async ({ range, users }) => {
const batch = await this.dingTalkService.fetchAttendanceResults({
startDate: range.startDate,
endDate: range.endDate,
userIds: users,
});
allResults.push(...batch);
completedRequests++;
fetchedCount += batch.length;
this.emit(
'fetching',
completedRequests,
totalRequests,
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${fetchedCount} 条记录`,
);
}
}
return batch;
},
);
for (const batch of results) allResults.push(...batch);
return allResults;
}
@@ -244,17 +329,28 @@ export class AttendanceImportService {
/**
* Query which dingIds already exist to skip duplicates.
*
* dingIds 去重后按每块 ≤1000 个切成数组,用 Promise.all 并发查询各块再合并,
* 避免无界 IN(dingIds) 触发 MySQL 参数上限/包大小错误;返回 Map 仍按 dingId 去重。
*/
private async getExistingRecordsByDingId(
results: DingTalkAttendanceResult[],
): Promise<Map<string, DingAttendanceRaw>> {
const dingIds = results.map((r) => r.checkId).filter(Boolean);
const dingIds = [...new Set(results.map((r) => r.checkId).filter(Boolean))];
if (dingIds.length === 0) return new Map();
const existing = await this.dingRawRepo.find({
where: { dingId: In(dingIds) },
});
return new Map(existing.map((entity) => [entity.dingId, entity]));
const chunks: string[][] = [];
for (let i = 0; i < dingIds.length; i += MAX_DING_IDS_PER_QUERY) {
chunks.push(dingIds.slice(i, i + MAX_DING_IDS_PER_QUERY));
}
const chunkResults = await Promise.all(
chunks.map((chunk) =>
this.dingRawRepo.find({
where: { dingId: In(chunk) },
}),
),
);
return new Map(chunkResults.flat().map((entity) => [entity.dingId, entity]));
}
private async refreshDuplicatePunchMetadata(
@@ -288,11 +384,15 @@ export class AttendanceImportService {
/**
* Map a DingTalk API result to a DingAttendanceRaw entity.
* 学生姓名来自调用前批量预取的 map避免逐条触发两次查询N+1
*/
private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
private mapToEntity(
r: DingTalkAttendanceResult,
studentNameByDingUserId: Map<string, string>,
): DingAttendanceRaw {
const entity = new DingAttendanceRaw();
entity.dingUserId = r.userId;
entity.userName = r.userName || await this.resolveStudentName(r.userId);
entity.userName = r.userName || studentNameByDingUserId.get(r.userId) || '';
entity.attendanceDate = r.workDate;
entity.dingId = r.checkId;
entity.attendanceType = r.checkType || 'OnDuty';
@@ -302,7 +402,9 @@ export class AttendanceImportService {
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
if (r.actualCheckTime) {
// checkType 白名单校验:非法类型无法确定是签到还是签退,不写时间,
// 避免污染 checkOutTime/checkInTime记录本身仍保留并计入导入
if (r.actualCheckTime && ATTENDANCE_CHECK_TYPE_WHITELIST.includes(r.checkType)) {
const dt = new Date(r.actualCheckTime);
if (!isNaN(dt.getTime())) {
if (r.checkType === 'OnDuty') {
@@ -318,13 +420,64 @@ export class AttendanceImportService {
return entity;
}
private async resolveStudentName(dingUserId: string): Promise<string> {
const mapping = await this.studentDingMappingRepo.findOne({
where: { dingUserId },
/**
* 批量预取 dingUserId → 学生姓名:先一次性查 StudentDingMapping
* 再一次性查 Student最后组装成 Map。避免 mapToEntity 逐条两次查询N+1
*/
private async buildStudentNameByDingUserId(
results: DingTalkAttendanceResult[],
): Promise<Map<string, string>> {
const dingUserIds = [
...new Set(
results
.filter((record) => !record.userName)
.map((record) => record.userId)
.filter(Boolean),
),
];
if (dingUserIds.length === 0) return new Map();
const mappings = await this.studentDingMappingRepo.find({
where: { dingUserId: In(dingUserIds) },
});
if (!mapping) return '';
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
return student?.name || '';
if (mappings.length === 0) return new Map();
const studentIds = [...new Set(mappings.map((mapping) => mapping.studentId))];
const students = await this.studentRepo.find({
where: { id: In(studentIds) },
});
const studentNameById = new Map(
students.map((student) => [student.id, student.name]),
);
const nameByDingUserId = new Map<string, string>();
for (const mapping of mappings) {
const name = studentNameById.get(mapping.studentId);
if (name) nameByDingUserId.set(mapping.dingUserId, name);
}
return nameByDingUserId;
}
/**
* 以固定并发上限执行异步 mapper返回结果保持输入顺序。
*/
private async mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index]);
}
}),
);
return results;
}
/**

View File

@@ -0,0 +1,76 @@
import { AttendanceReportService } from './attendance-report.service';
describe('AttendanceReportService.getAlerts', () => {
function createService(records: unknown[]) {
const attendanceRepo = {
createQueryBuilder: jest.fn(() => {
const qb: any = {
leftJoinAndSelect: jest.fn(() => qb),
where: jest.fn(() => qb),
andWhere: jest.fn(() => qb),
orderBy: jest.fn(() => qb),
addOrderBy: jest.fn(() => qb),
getMany: jest.fn().mockResolvedValue(records),
};
return qb;
}),
};
return new AttendanceReportService(attendanceRepo as never, {} as never);
}
const absent = (studentId: number, date: string) => ({
studentId,
status: 'absent',
attendanceDate: date,
student: { name: `S${studentId}` },
class: { name: '一班' },
});
it('returns the most recent run when consecutive runs have equal length', async () => {
const service = createService([
absent(1, '2026-07-01'),
absent(1, '2026-07-02'),
absent(1, '2026-07-05'),
absent(1, '2026-07-06'),
]);
const alerts = await service.getAlerts(30, 2);
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
studentId: 1,
studentName: 'S1',
className: '一班',
type: '缺勤',
count: 2,
lastDate: '2026-07-06',
});
});
it('uses the latest date of the longest run as lastDate', async () => {
const service = createService([
absent(2, '2026-07-01'),
absent(2, '2026-07-02'),
absent(2, '2026-07-03'),
absent(2, '2026-07-06'),
absent(2, '2026-07-07'),
]);
const alerts = await service.getAlerts(30, 2);
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({ count: 3, lastDate: '2026-07-03' });
});
it('does not merge non-consecutive absences into one run', async () => {
const service = createService([
absent(3, '2026-07-01'),
absent(3, '2026-07-03'),
absent(3, '2026-07-05'),
]);
const alerts = await service.getAlerts(30, 3);
expect(alerts).toHaveLength(0);
});
});

View File

@@ -171,27 +171,72 @@ export class AttendanceReportService {
lastDate: string;
}> = [];
let current: (typeof alerts)[0] | null = null;
// 按「学生 + 状态」聚合记录日期,再做真实的连续日判断:
// 仅按状态/条数统计会把不连续的日子误算成连续缺勤/迟到。
const grouped = new Map<
string,
{
studentId: number;
studentName: string;
className: string;
status: string;
dates: Set<string>;
}
>();
for (const r of records) {
const name = r.student?.name || '';
const className = r.class?.name || '';
const status = r.status === 'absent' ? '缺勤' : '迟到';
if (current && current.studentId === r.studentId && current.type === status) {
current.count++;
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
} else {
if (current && current.count >= threshold) alerts.push({ ...current });
current = {
const key = `${r.studentId}|${r.status}`;
let group = grouped.get(key);
if (!group) {
group = {
studentId: r.studentId,
studentName: name,
className,
type: status,
count: 1,
lastDate: r.attendanceDate,
studentName: r.student?.name || '',
className: r.class?.name || '',
status: r.status,
dates: new Set(),
};
grouped.set(key, group);
}
group.dates.add(r.attendanceDate);
}
for (const group of grouped.values()) {
// 按日期倒序聚合:长度相同的连续段取「最近一段」(先遇到者胜出),
// lastDate 指向该段内最新日期,保持重构前的原语义。
const sortedDates = [...group.dates].sort((a, b) => (a < b ? 1 : -1));
let bestRun = 0;
let bestRunLastDate = '';
let run = 1;
let runLastDate = sortedDates[0] || '';
for (let i = 1; i <= sortedDates.length; i++) {
const date = sortedDates[i];
if (
i < sortedDates.length &&
dayjs.utc(sortedDates[i - 1]).diff(dayjs.utc(date), 'day') === 1
) {
// 倒序连续runLastDate 保持该段内最早遇到(即最新)的日期
run++;
} else {
if (run > bestRun) {
bestRun = run;
bestRunLastDate = runLastDate;
}
if (i < sortedDates.length) {
run = 1;
runLastDate = date;
}
}
}
if (bestRun >= threshold) {
alerts.push({
studentId: group.studentId,
studentName: group.studentName,
className: group.className,
type: group.status === 'absent' ? '缺勤' : '迟到',
count: bestRun,
lastDate: bestRunLastDate,
});
}
}
if (current && current.count >= threshold) alerts.push(current);
return alerts;
}
}

View File

@@ -25,6 +25,22 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
...periodConfigRepoOverrides,
};
// saveAttendancePeriodConfigs/resetAttendancePeriodConfigs 现在在事务内写库:
// 事务 manager 的 clear/create/save 委托给同一个 periodConfigRepo mock
// 保证原有断言(如 savedPeriods 捕获)仍然成立。
const transactionManager = {
clear: jest.fn().mockImplementation(() => periodConfigRepo.clear()),
create: jest.fn().mockImplementation((_entity: unknown, data: unknown) =>
Array.isArray(data) ? data : periodConfigRepo.create(data),
),
save: jest.fn().mockImplementation((entities: unknown) => periodConfigRepo.save(entities)),
};
const dataSource = {
transaction: jest.fn(async (cb: (manager: unknown) => Promise<unknown>) =>
cb(transactionManager),
),
};
return new AttendanceService(
{} as never, // attendanceRepo
{} as never, // dingRawRepo
@@ -38,7 +54,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
{} as never, // attendanceSessionRepo
{} as never, // attendanceDeviceRepo
periodConfigRepo as never,
{} as never, // dataSource
dataSource as never,
);
}

View File

@@ -0,0 +1,441 @@
import { BillsGenerationService } from './bills-generation.service';
import type { GenerateBillsDto } from './dto/bill.dto';
import { Bill, RoomExpense, PersonalExpense, Occupancy } from '../entities';
function mockQueryBuilder<T>(results: T[]) {
return {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(results),
};
}
function createService(existingPeriodBills: Array<Partial<Bill>> = []) {
const lockQb = {
setLock: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(existingPeriodBills),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
const manager = {
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
create: jest.fn((_entity: unknown, value: unknown) => value),
save: jest.fn(async (value: Record<string, unknown>) => ({ id: 1, ...value })),
// advisory lock默认首请求可拿到锁RELEASE_LOCK 直接成功
query: jest.fn(async (sql: string) =>
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
),
};
const dataSource = { transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)) };
const roomExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<RoomExpense>([])),
};
const occRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<Occupancy>([])),
};
const personalExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
};
const service = new BillsGenerationService(
roomExpRepo as never,
personalExpRepo as never,
occRepo as never,
dataSource as never,
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
);
return { service, dataSource, manager, lockQb, roomExpRepo, occRepo, personalExpRepo };
}
const PERIOD = { periodStart: '2026-06-01', periodEnd: '2026-06-30' } as GenerateBillsDto;
describe('BillsGenerationService — 周期内重复生成并发防重', () => {
it('rejects duplicate generation inside the transaction with a locked re-check', async () => {
const { service, dataSource, manager, lockQb } = createService([
{ id: 1, periodStart: '2026-06-01', periodEnd: '2026-06-30' },
]);
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(lockQb.where).toHaveBeenCalledWith('b.periodStart = :periodStart', {
periodStart: '2026-06-01',
});
expect(lockQb.andWhere).toHaveBeenCalledWith('b.periodEnd = :periodEnd', {
periodEnd: '2026-06-30',
});
expect(lockQb.getMany).toHaveBeenCalledTimes(1);
expect(manager.save).not.toHaveBeenCalled();
});
it('still performs the locked re-check and generates bills when the period is free', async () => {
const { service, dataSource, manager, lockQb, roomExpRepo, occRepo } = createService();
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1,
roomId: 1,
expenseType: 'water',
amount: 100,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1,
roomId: 1,
studentId: 10,
stayType: 'short',
billingStartDate: '2026-06-01',
billingEndDate: '2026-06-30',
} as Occupancy,
]),
);
const result = await service.generateBillsOnce(PERIOD);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(lockQb.getMany).toHaveBeenCalledTimes(1);
expect(manager.save).toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
message: '成功生成 1 条账单',
count: 1,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
}),
);
});
it('loads occupancies for all rooms in one batched query (no per-room N+1)', async () => {
const { service, manager, occRepo, roomExpRepo } = createService();
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1,
roomId: 1,
expenseType: 'water',
amount: 100,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
} as RoomExpense,
{
id: 2,
roomId: 2,
expenseType: 'water',
amount: 200,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
const batchQb = mockQueryBuilder<Occupancy>([
{
id: 1,
roomId: 1,
studentId: 10,
stayType: 'short',
billingStartDate: '2026-06-01',
billingEndDate: '2026-06-30',
} as Occupancy,
{
id: 2,
roomId: 2,
studentId: 11,
stayType: 'short',
billingStartDate: '2026-06-01',
billingEndDate: '2026-06-30',
} as Occupancy,
]);
// 第 1 次roomIds 聚合查询(长租 active 入住);第 2 次:全房间批量入住查询
(occRepo.createQueryBuilder as jest.Mock)
.mockReturnValueOnce(mockQueryBuilder<Occupancy>([]))
.mockReturnValueOnce(batchQb);
const result = await service.generateBillsOnce(PERIOD);
// 入住查询只发两次roomIds 聚合 1 次 + roomIds 批量查询 1 次(不再按房间循环 N+1
expect(occRepo.createQueryBuilder).toHaveBeenCalledTimes(2);
expect(batchQb.where).toHaveBeenCalledWith('o.roomId IN (:...roomIds)', {
roomIds: [1, 2],
});
expect(batchQb.andWhere).toHaveBeenCalledWith('o.billingStartDate <= :periodEnd', {
periodEnd: '2026-06-30',
});
expect(batchQb.andWhere).toHaveBeenCalledWith(
'(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)',
{ periodStart: '2026-06-01' },
);
// 内存分组后仍按原逻辑为每个房间的学生生成账单
expect(manager.save).toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
message: '成功生成 2 条账单',
count: 2,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
}),
);
});
it('only one of two concurrent requests succeeds; the second sees committed bills and rejects', async () => {
const committed: Array<Partial<Bill>> = [];
const lockQb = {
setLock: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockImplementation(async () => committed),
};
const manager = {
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
create: jest.fn((_entity: unknown, value: unknown) => value),
save: jest.fn(async (value: Record<string, unknown>) => {
if ('studentId' in value && 'periodStart' in value) {
const saved = { id: committed.length + 1, ...value };
committed.push(saved);
return saved;
}
return { id: 1, ...value };
}),
query: jest.fn(async (sql: string) =>
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const roomExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1,
roomId: 1,
expenseType: 'water',
amount: 100,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
} as RoomExpense,
]),
),
};
const occRepo = {
createQueryBuilder: jest.fn().mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1,
roomId: 1,
studentId: 10,
stayType: 'short',
billingStartDate: '2026-06-01',
billingEndDate: '2026-06-30',
} as Occupancy,
]),
),
};
const personalExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
};
const service = new BillsGenerationService(
roomExpRepo as never,
personalExpRepo as never,
occRepo as never,
dataSource as never,
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
);
const first = await service.generateBillsOnce(PERIOD);
expect(first.count).toBe(1);
expect(committed).toHaveLength(1);
// 第二个并发请求在锁释放后能看到第一个请求已提交的账单,直接抛「账单已生成」
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成');
expect(committed).toHaveLength(1);
});
it('throws ConflictException when the advisory lock cannot be acquired in time', async () => {
const { service, manager } = createService();
(manager.query as jest.Mock).mockResolvedValue([{ acquired: 0 }]);
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成');
expect(manager.query).toHaveBeenCalledWith(
"SELECT GET_LOCK('gongxue:bills-gen:2026-06-01-2026-06-30', 5) AS acquired",
);
// 未拿到锁:不进入重复检查与写入
expect(manager.save).not.toHaveBeenCalled();
});
it('only one of two truly concurrent first-time requests succeeds (advisory lock)', async () => {
const committed: Array<Partial<Bill>> = [];
let lockHeld = false;
let markSecondArrived!: () => void;
const secondArrived = new Promise<void>((resolve) => {
markSecondArrived = resolve;
});
let releaseFirstSave!: () => void;
const lockQb = {
setLock: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockImplementation(async () => committed),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
const manager = {
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
create: jest.fn((_entity: unknown, value: unknown) => value),
save: jest.fn(async (value: Record<string, unknown>) => {
if ('studentId' in value && 'periodStart' in value) {
// 第一个请求已持有锁并开始写账单,此时放第二个并发请求进来抢同一把锁
markSecondArrived();
await new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const saved = { id: committed.length + 1, ...value };
committed.push(saved);
return saved;
}
return { id: 1, ...value };
}),
query: jest.fn(async (sql: string) => {
if (sql.includes('GET_LOCK')) {
if (lockHeld) return [{ acquired: 0 }];
lockHeld = true;
return [{ acquired: 1 }];
}
if (sql.includes('RELEASE_LOCK')) {
lockHeld = false;
return [{ released: 1 }];
}
return [];
}),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const roomExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1,
roomId: 1,
expenseType: 'water',
amount: 100,
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
} as RoomExpense,
]),
),
};
const occRepo = {
createQueryBuilder: jest.fn().mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1,
roomId: 1,
studentId: 10,
stayType: 'short',
billingStartDate: '2026-06-01',
billingEndDate: '2026-06-30',
} as Occupancy,
]),
),
};
const personalExpRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
};
const service = new BillsGenerationService(
roomExpRepo as never,
personalExpRepo as never,
occRepo as never,
dataSource as never,
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
);
const firstPromise = service.generateBillsOnce(PERIOD);
await secondArrived;
// 第二个并发请求在第一个请求持有锁期间尝试获取同一把锁 → 超时/被占用 → ConflictException
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成');
expect(committed).toHaveLength(0);
releaseFirstSave();
const first = await firstPromise;
expect(first.count).toBe(1);
expect(committed).toHaveLength(1);
expect(lockHeld).toBe(false);
});
it('saves all BillItems of a bill in one batched save', async () => {
const { service, manager, personalExpRepo } = createService();
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([
{
id: 9,
studentId: 10,
roomId: 1,
expenseType: 'meal',
description: '三餐',
amount: 50,
expenseDate: '2026-06-05',
status: 'active',
} as PersonalExpense,
]),
);
await service.generateBillsOnce(PERIOD);
const arraySaves = (manager.save as jest.Mock).mock.calls.filter(
([value]) => Array.isArray(value),
);
expect(arraySaves).toHaveLength(1);
expect(arraySaves[0][0]).toHaveLength(1);
expect(arraySaves[0][0][0]).toEqual(
expect.objectContaining({
billId: 1,
personalExpenseId: 9,
expenseType: 'meal',
studentAmount: 50,
}),
);
});
it('rounds personalAmount to two decimals before saving', async () => {
const { service, manager, personalExpRepo } = createService();
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([
{
id: 10,
studentId: 10,
roomId: 1,
expenseType: 'meal',
description: '三餐',
amount: 0.30000000000000004,
expenseDate: '2026-06-05',
status: 'active',
} as PersonalExpense,
]),
);
await service.generateBillsOnce(PERIOD);
const billSave = (manager.save as jest.Mock).mock.calls.find(
([value]) => !Array.isArray(value) && value?.studentId === 10,
);
expect(billSave).toBeDefined();
expect(billSave![0]).toEqual(
expect.objectContaining({
personalAmount: 0.3,
totalAmount: 0.3,
}),
);
});
});

View File

@@ -1,20 +1,44 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities';
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy } from '../entities';
import { WalletsService } from '../wallets/wallets.service';
import type { GenerateBillsDto } from './dto/bill.dto';
import dayjs from '../common/dayjs';
/** 把 TypeORM 可能 hydrate 成 Date 的 date-only 字段归一化为 YYYY-MM-DD 字符串。 */
function toDateOnly(value: Date | string | null | undefined): string {
if (value == null) return '';
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return String(value);
}
/** YYYY-MM-DD 字符串(字典序即时间序)取较大/较小者。 */
function maxDateOnly(a: string, b: string): string {
return a > b ? a : b;
}
function minDateOnly(a: string, b: string): string {
return a < b ? a : b;
}
/** YYYY-MM-DD 字符串相差的天数a 晚于 b 返回负值)。 */
function daysBetweenDateOnly(a: string, b: string): number {
const [ay, am, ad] = a.split('-').map(Number);
const [by, bm, bd] = b.split('-').map(Number);
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
}
@Injectable()
export class BillsGenerationService {
constructor(
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
) {}
@@ -26,14 +50,6 @@ export class BillsGenerationService {
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
}
const pStart = new Date(`${periodStart}T00:00:00Z`);
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
);
}
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
@@ -42,7 +58,16 @@ export class BillsGenerationService {
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
const longTermOccupancies: Occupancy[] = [];
// 长租入住:即使本周期没有费用记录,也按长租计费纳入开票范围
const longTermOccupancies: Occupancy[] = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.stayType = :stayType', { stayType: 'long' })
.andWhere('o.status = :status', { status: 'active' })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const roomExpMap = new Map<number, RoomExpense[]>();
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
@@ -60,16 +85,36 @@ export class BillsGenerationService {
{ shared: number; items: Array<Record<string, unknown>> }
>();
// 一次性按 roomIds In(...) 查询本周期内全部在住/曾住的入住记录,内存按 roomId 分组,
// 避免对每个房间循环 createQueryBuilder(...).getMany()N+1
// 有意不按 o.status 过滤:这里要查这些房间在本周期内全部在住/曾住的入住记录来分摊费用,
// 退宿后归档的入住记录只要 billingStartDate/billingEndDate 覆盖本周期仍应参与分摊
// (与 occupancy-operations.getRoomOccupanciesInPeriod 的计费语义一致)。
// status='active' 过滤只用于上面 roomIds 聚合(决定哪些房间进入开票范围),不用于分摊查询。
const roomIdList = [...roomIds];
const allOccupancies: Occupancy[] =
roomIdList.length > 0
? await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.roomId IN (:...roomIds)', { roomIds: roomIdList })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', {
periodStart,
})
.getMany()
: [];
const occupanciesByRoom = new Map<number, Occupancy[]>();
for (const occupancy of allOccupancies) {
const list = occupanciesByRoom.get(occupancy.roomId) || [];
list.push(occupancy);
occupanciesByRoom.set(occupancy.roomId, list);
}
for (const roomId of roomIds) {
const expenses = roomExpMap.get(roomId) || [];
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const occupancies = occupanciesByRoom.get(roomId) || [];
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
@@ -95,21 +140,21 @@ export class BillsGenerationService {
studentBillData.set(occupancy.studentId, data);
}
// 日期统一用 YYYY-MM-DD 字符串比较/运算,避免 TypeORM 把 date 列 hydrate 成 Date 后比较不可靠
const studentDays = shortTermOccs.map((occupancy) => {
const start = new Date(
Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()),
);
const end = occupancy.billingEndDate
? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
const startStr = toDateOnly(occupancy.billingStartDate);
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
if (!startStr) return { studentId: occupancy.studentId, days: 0 };
const start = maxDateOnly(startStr, periodStart);
const end = minDateOnly(endStr, periodEnd);
const days = end < start ? 0 : daysBetweenDateOnly(start, end) + 1;
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
for (const expense of expenses) {
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
for (const [index, entry] of eligibleDays.entries()) {
@@ -167,44 +212,81 @@ export class BillsGenerationService {
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
const bills = await this.dataSource.transaction(async (manager) => {
const generated: Bill[] = [];
for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
let bill = await manager.save(
manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
// 首次生成的并发防护FOR UPDATE 对不存在的行不加锁,两个并发请求都能通过检查;
// 这里在事务内先拿按周期 key 的 MySQL advisory lockGET_LOCK 对不存在的 key 同样生效),
// 失败/超时直接抛 ConflictException重复检查放进锁内。financialOperations.run 只覆盖
// 带 operationId 的幂等场景,无 operationId 的并发首次生成靠这把锁兜底。
const lockName = `gongxue:bills-gen:${periodStart}-${periodEnd}`;
const lockRows = (await manager.query(
`SELECT GET_LOCK('${lockName}', 5) AS acquired`,
)) as unknown as Array<{ acquired?: unknown }> | undefined;
if (Number(lockRows?.[0]?.acquired ?? 0) !== 1) {
throw new ConflictException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单正在生成中,请勿重复提交`,
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
for (const item of items)
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
generated.push(bill);
}
return generated;
try {
// 周期内重复生成检查移进事务内,并对同一 (periodStart, periodEnd) 加锁复查:
// 并发请求会在锁释放后看到已提交的账单并抛「账单已生成」,保证只有一个请求成功。
const existingBills = await manager
.createQueryBuilder(Bill, 'b')
.setLock('pessimistic_write')
.where('b.periodStart = :periodStart', { periodStart })
.andWhere('b.periodEnd = :periodEnd', { periodEnd })
.getMany();
if (existingBills.length > 0) {
throw new BadRequestException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
);
}
const generated: Bill[] = [];
for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
let bill = await manager.save(
manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: Number(personal.toFixed(2)),
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
// 逐条 save 改为批量一次保存,减少往返
if (items.length > 0) {
await manager.save(
manager.create(
BillItem,
items.map((item) => ({ ...item, billId: bill.id })),
),
);
}
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
generated.push(bill);
}
return generated;
} finally {
await manager.query(`SELECT RELEASE_LOCK('${lockName}')`);
}
});
return {
message: `成功生成 ${bills.length} 条账单`,
@@ -222,12 +304,10 @@ export class BillsGenerationService {
periodEnd: string,
monthlyRate: number,
) {
const activeStart =
occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd =
occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
const startStr = toDateOnly(occupancy.billingStartDate);
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
const activeStart = startStr > periodStart ? startStr : periodStart;
const activeEnd = endStr && endStr < periodEnd ? endStr : periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);

View File

@@ -0,0 +1,196 @@
import { BillsController } from './bills.controller';
import { NotificationType } from '../entities/notification.entity';
describe('BillsController — 通知批量预取学生', () => {
function createController(options: {
bills?: Array<Record<string, unknown>>;
students?: Array<Record<string, unknown>>;
} = {}) {
const { bills = [], students = [] } = options;
const service = {
generateBills: jest.fn().mockResolvedValue({
periodStart: '2026-06-01',
periodEnd: '2026-06-30',
count: bills.length,
bills,
}),
batchUpdateStatus: jest.fn().mockResolvedValue({}),
};
const studentRepo = {
find: jest.fn().mockResolvedValue(students),
findOne: jest.fn(),
};
const billRepo = { findBy: jest.fn().mockResolvedValue(bills) };
const notificationsService = {
create: jest.fn().mockResolvedValue(undefined),
};
const logService = { log: jest.fn().mockResolvedValue(undefined) };
const controller = new BillsController(
service as never,
{} as never,
logService as never,
notificationsService as never,
studentRepo as never,
billRepo as never,
);
return { controller, service, studentRepo, billRepo, notificationsService };
}
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
it('batches student lookup before sending bill_generated notifications', async () => {
const { controller, studentRepo, notificationsService } = createController({
bills: [
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
{ id: 2, studentId: 10, totalAmount: 80, status: 'unpaid' },
{ id: 3, studentId: 11, totalAmount: 50, status: 'unpaid' },
],
students: [
{ id: 10, userId: 100 },
{ id: 11, userId: null },
],
});
await controller.generateBills({} as never, req as never);
expect(studentRepo.find).toHaveBeenCalledTimes(1);
const where = (studentRepo.find as jest.Mock).mock.calls[0][0].where;
expect(where.id.value).toEqual([10, 11]);
expect(studentRepo.findOne).not.toHaveBeenCalled();
expect(notificationsService.create).toHaveBeenCalledTimes(2);
expect(notificationsService.create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
recipientIds: [100],
type: NotificationType.BILL_GENERATED,
title: '新账单',
}),
);
});
it('notifies only paid bills in batch status update with a single student query', async () => {
const { controller, studentRepo, notificationsService, billRepo } = createController({
bills: [
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
{ id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' },
{ id: 3, studentId: 10, totalAmount: 80, status: 'paid' },
],
students: [{ id: 10, userId: 100 }],
});
// 更新前状态1/2/3 都未付款;更新后 1/3 变为 paid → 只通知 1/3幂等重确认不重复通知
billRepo.findBy.mockResolvedValueOnce([
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
{ id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' },
{ id: 3, studentId: 10, totalAmount: 80, status: 'unpaid' },
]);
await controller.batchUpdateStatus({ ids: [1, 2, 3], status: 'paid' }, req as never);
expect(studentRepo.find).toHaveBeenCalledTimes(1);
expect(notificationsService.create).toHaveBeenCalledTimes(2);
expect(notificationsService.create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
recipientIds: [100],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: expect.stringContaining('#1'),
}),
);
expect(notificationsService.create).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ content: expect.stringContaining('#3') }),
);
});
it('a notification failure does not stop notifications for other bills', async () => {
let call = 0;
const notificationsService = {
create: jest.fn().mockImplementation(() => {
call += 1;
if (call === 1) return Promise.reject(new Error('notify down'));
return Promise.resolve(undefined);
}),
};
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]),
findOne: jest.fn(),
};
const billRepo = {
findBy: jest.fn()
.mockResolvedValueOnce([
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
{ id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' },
])
.mockResolvedValue([
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
{ id: 2, studentId: 10, totalAmount: 50, status: 'paid' },
]),
};
const service = {
batchUpdateStatus: jest.fn().mockResolvedValue({}),
generateBills: jest.fn(),
};
const controller = new BillsController(
service as never,
{} as never,
{ log: jest.fn().mockResolvedValue(undefined) } as never,
notificationsService as never,
studentRepo as never,
billRepo as never,
);
await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never);
expect(notificationsService.create).toHaveBeenCalledTimes(2);
});
it('logs notification failures instead of swallowing them silently', async () => {
let call = 0;
const notificationsService = {
create: jest.fn().mockImplementation(() => {
call += 1;
if (call === 1) return Promise.reject(new Error('notify down'));
return Promise.resolve(undefined);
}),
};
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]),
findOne: jest.fn(),
};
const billRepo = {
findBy: jest.fn()
.mockResolvedValueOnce([
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
{ id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' },
])
.mockResolvedValue([
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
{ id: 2, studentId: 10, totalAmount: 50, status: 'paid' },
]),
};
const service = {
batchUpdateStatus: jest.fn().mockResolvedValue({}),
generateBills: jest.fn(),
};
const controller = new BillsController(
service as never,
{} as never,
{ log: jest.fn().mockResolvedValue(undefined) } as never,
notificationsService as never,
studentRepo as never,
billRepo as never,
);
const warnSpy = jest.spyOn((controller as any).logger, 'warn').mockImplementation(() => undefined);
try {
await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never);
// Promise.allSettled失败通知不阻断其他通知且失败被记录到日志
expect(notificationsService.create).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('notify down'));
} finally {
warnSpy.mockRestore();
}
});
});

View File

@@ -12,6 +12,7 @@ import {
Res,
Req,
ParseIntPipe,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
@@ -21,7 +22,13 @@ import { NotificationType } from '../entities/notification.entity';
import { Student } from '../entities/student.entity';
import { Bill } from '../entities/bill.entity';
import { BillsExportService } from './bills-export.service';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import {
BatchIdsBodyDto,
CancelBillDto,
GenerateBillsDto,
UpdateBillStatusDto,
} from './dto/bill.dto';
import { BatchIdsDto } from '../common/batch-ids.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
@@ -38,6 +45,8 @@ interface AuthenticatedRequest {
@UseGuards(JwtAuthGuard)
@Controller('bills')
export class BillsController {
private readonly logger = new Logger(BillsController.name);
constructor(
private service: BillsService,
private exportService: BillsExportService,
@@ -56,18 +65,20 @@ export class BillsController {
});
// Send bill_generated notifications
try {
for (const bill of result.bills) {
const student = await this.studentRepo.findOne({ where: { id: bill.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_GENERATED,
title: '新账单',
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
});
}
}
} catch (_) { /* don't block response */ }
await this.sendPaidNotifications(
this.studentRepo,
this.notificationsService,
result.bills,
(bill) => ({
type: NotificationType.BILL_GENERATED,
title: '新账单',
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
}),
);
} catch (err) {
// 预取/通知失败不影响主流程,但记录日志避免静默吞错
this.logger.warn(`账单生成通知发送失败: ${String(err)}`);
}
return result;
}
@@ -87,113 +98,6 @@ export class BillsController {
});
}
@Get(':id')
@RequirePermission('bill:view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Put(':id/status')
@RequirePermission('bill:confirm')
async updateStatus(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBillStatusDto,
@Request() req: AuthenticatedRequest,
) {
const result = await this.service.updateStatus(id, dto);
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
});
// Send bill_paid notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
});
}
} catch (_) { /* don't block response */ }
return result;
}
@Put('batch/status')
@RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchUpdateStatus(body.ids, body.status);
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
});
// Send bill_paid notifications (batch)
try {
const bills = await this.billRepo.findBy({ id: In(body.ids) });
for (const bill of bills) {
const student = await this.studentRepo.findOne({ where: { id: bill.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`,
});
}
}
} catch (_) { /* don't block response */ }
return result;
}
@Post(':id/cancel')
@RequirePermission('bill:delete')
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.cancel(id, dto, req.user?.id);
await logAudit(this.logService, req, {
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
});
return result;
}
@Delete(':id')
@RequirePermission('bill:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const result = await this.service.remove(id);
await logAudit(this.logService, req, {
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('bill:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('bill:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Post('batch/delete')
@RequirePermission('bill:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchRemove(body.ids);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
});
return result;
}
@Get('export/excel')
@RequirePermission('bill:export-excel')
async exportExcel(
@@ -226,4 +130,157 @@ export class BillsController {
});
return this.exportService.exportStudentPdf(id, res);
}
@Get(':id')
@RequirePermission('bill:view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Put('batch/status')
@RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: BatchIdsBodyDto, @Request() req: AuthenticatedRequest) {
const ids = body.ids || [];
// 更新前先取当前状态:只对「本次从非 paid 变为 paid」的账单发通知幂等重确认不重复打扰
const before = await this.billRepo.findBy({ id: In(ids) });
const paidBefore = new Set(
before.filter((bill) => bill.status === 'paid').map((bill) => bill.id),
);
const result = await this.service.batchUpdateStatus(ids, body.status);
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', detail: `IDs: ${ids.join(',')}`,
});
// Send bill_paid notifications (batch)DB 查询失败正常抛出,只有通知失败被吞(记日志)
const bills = await this.billRepo.findBy({ id: In(ids) });
const paidBills = bills.filter((bill) => bill.status === 'paid' && !paidBefore.has(bill.id));
try {
await this.sendPaidNotifications(this.studentRepo, this.notificationsService, paidBills);
} catch (err) {
this.logger.warn(`账单确认通知发送失败: ${String(err)}`);
}
return result;
}
@Put(':id/status')
@RequirePermission('bill:confirm')
async updateStatus(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBillStatusDto,
@Request() req: AuthenticatedRequest,
) {
const prior = await this.billRepo.findOne({ where: { id } });
const result = await this.service.updateStatus(id, dto);
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
});
// 仅当本次从非 paid 变为 paid 才发通知(幂等),复用批量通知 helper
if (result.status === 'paid' && prior?.status !== 'paid') {
try {
await this.sendPaidNotifications(this.studentRepo, this.notificationsService, [result]);
} catch (err) {
this.logger.warn(`账单已确认通知发送失败: ${String(err)}`);
}
}
return result;
}
@Post(':id/cancel')
@RequirePermission('bill:delete')
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.cancel(id, dto, req.user?.id);
await logAudit(this.logService, req, {
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill',
detail: Array.from(dto.reason ?? '')
.map((ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch))
.join('')
.trim(),
});
return result;
}
@Delete(':id')
@RequirePermission('bill:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const result = await this.service.remove(id);
await logAudit(this.logService, req, {
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('bill:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('bill:purge')
async batchPurge(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Post('batch/delete')
@RequirePermission('bill:delete')
async batchRemove(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const ids = body.ids || [];
const result = await this.service.batchRemove(ids);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量归档账单', detail: `IDs: ${ids.join(',')}`,
});
return result;
}
/**
* 批量预取学生并并发发送账单通知generateBills/batchUpdateStatus 共用)。
* 内部用 Promise.allSettled 并发发送:单个通知失败只记录日志、不影响其他通知与主流程。
* notificationFor 缺省时发送「已确认收款」通知batchUpdateStatus 场景)。
*/
private async sendPaidNotifications(
studentRepo: Repository<Student>,
notificationsService: NotificationsService,
bills: Bill[],
notificationFor: (
bill: Bill,
) => { type: NotificationType; title: string; content: string } = (bill) => ({
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`,
}),
): Promise<void> {
if (bills.length === 0) return;
// 批量预取学生(一次 In 查询),避免每张账单 studentRepo.findOneN+1
const studentIds = [...new Set(bills.map((bill) => bill.studentId))];
const students = await studentRepo.find({ where: { id: In(studentIds) } });
const userByStudentId = new Map(
students.filter((student) => student.userId).map((student) => [student.id, student.userId]),
);
const results = await Promise.allSettled(
bills.map((bill) => {
const userId = userByStudentId.get(bill.studentId);
if (!userId) return Promise.resolve(undefined);
const notification = notificationFor(bill);
return notificationsService.create({
recipientIds: [userId],
type: notification.type,
title: notification.title,
content: notification.content,
});
}),
);
for (const result of results) {
if (result.status === 'rejected') {
// 不吞错:失败记录到日志,通知失败不影响主流程
this.logger.warn(`账单通知发送失败: ${String(result.reason)}`);
}
}
}
}

View File

@@ -71,7 +71,11 @@ describe('BillsService — generateBills', () => {
await (itemRepo.save as jest.Mock)(value);
return { id: value.id || 1, ...value };
}),
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
createQueryBuilder: jest.fn(() => ({ setLock: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
// advisory lock事务内 GET_LOCK/RELEASE_LOCK 直接成功
query: jest.fn(async (sql: string) =>
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
),
})),
query: jest.fn().mockResolvedValue([]),
};
@@ -390,26 +394,27 @@ describe('BillsService — generateBills', () => {
]),
);
// Use sequential query builder returns: first call → room 1 occs, second → room 2 occs
// Use sequential query builder returns:
// call 1 → 本周期长租入住(该测试无长租,返回空)
// call 2 → roomIds 批量入住查询room 1 + room 2 全部在住记录)
// NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices
let callCount = 0;
(occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
stayType: 'short', room: undefined,
} as Occupancy,
]);
return mockQueryBuilder<Occupancy>([]);
}
return mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
@@ -562,21 +567,25 @@ describe('BillsService — allocation rounding boundary', () => {
create: (_entity: unknown, value: any) => value,
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
createQueryBuilder: jest.fn(() => ({
setLock: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
})),
// advisory lock事务内 GET_LOCK/RELEASE_LOCK 直接成功
query: jest.fn(async (sql: string) =>
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
),
})),
};
const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any;
const generation = new BillsGenerationService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
walletsService,
);

View File

@@ -12,6 +12,7 @@ import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill
import { WalletsService } from '../wallets/wallets.service';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { BillsGenerationService } from './bills-generation.service';
import { escapeLike } from '../common/like-escape';
interface AgentBillRow {
billId: string | number;
@@ -143,11 +144,11 @@ export class BillsService {
const billId = Number(query.keyword);
if (Number.isInteger(billId) && billId > 0) {
qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', {
keyword: `%${query.keyword}%`,
keyword: `%${escapeLike(query.keyword)}%`,
billId,
});
} else {
qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` });
qb.andWhere('student.name LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` });
}
}
if (query.periodStart)

View File

@@ -1,4 +1,14 @@
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export class GenerateBillsDto {
@IsOptional()
@@ -7,7 +17,7 @@ export class GenerateBillsDto {
operationId?: string;
@IsString()
@Matches(/^\d{4}-\d{2}$/)
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
billingMonth: string;
@IsOptional()
@@ -36,3 +46,14 @@ export class CancelBillDto {
@MaxLength(300)
reason: string;
}
/** 批量确认账单请求体ids 校验为整数数组且最多 500 条。 */
export class BatchIdsBodyDto {
@IsArray()
@IsInt({ each: true })
@ArrayMaxSize(500)
ids: number[];
@IsIn(['unpaid', 'partially_paid', 'paid'])
status: 'unpaid' | 'partially_paid' | 'paid';
}

View File

@@ -201,7 +201,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
let scheduleRepo: jest.Mocked<
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
@@ -226,17 +226,30 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
Pick<Repository<Organization>, 'findOne'>
>;
// syncScheduleFromRental 现在把 read+write 放进 scheduleRepo.manager 事务,
// 事务内通过 manager.getRepository(ClassSchedule) 拿到绑定事务的同一 mock。
const scheduleManager = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
getRepository: jest.fn((entity: unknown) =>
entity === ClassSchedule ? scheduleRepo : undefined,
),
}),
),
};
scheduleRepo = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
manager: scheduleManager,
} as jest.Mocked<
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
@@ -265,8 +278,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const dto: CreateRentalDto = {
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
startDate: '2026-09-01',
endDate: '2026-09-30',
};
const classroom = { id: 1, status: 'available' } as Classroom;
const hostOrganization = {
@@ -294,7 +307,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.findOne.mockResolvedValue(null);
scheduleRepo.find.mockResolvedValue([]);
scheduleRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassSchedule,
);
@@ -310,8 +323,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
classroomId: 1,
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
startDate: '2026-09-01',
endDate: '2026-09-30',
status: 'active',
}),
);
@@ -321,8 +334,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
classId: null,
startTime: '00:00',
endTime: '23:59',
startDate: '2026-03-01',
endDate: '2026-03-31',
startDate: '2026-09-01',
endDate: '2026-09-30',
subject: 'Organization A 租赁',
teacherId: null,
scheduleType: 'RENTAL',
@@ -332,6 +345,63 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
);
expect(scheduleRepo.save).toHaveBeenCalled();
});
it('creates one RENTAL schedule row per weekday for a multi-day rental', async () => {
const dto: CreateRentalDto = {
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-09-01', // 周二
endDate: '2026-09-05', // 周六
};
classroomRepo.findOne.mockResolvedValue({ id: 1, status: 'available' } as Classroom);
organizationRepo.findOne
.mockResolvedValueOnce({
id: 1,
name: 'Host',
isHost: true,
status: 'active',
} as Organization)
.mockResolvedValueOnce({
id: 2,
name: 'Organization A',
isHost: false,
status: 'active',
} as Organization);
rentalRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassroomRental,
);
rentalRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental),
);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.find.mockResolvedValue([]);
scheduleRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassSchedule,
);
scheduleRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule),
);
await service.create(dto);
expect(scheduleRepo.create).toHaveBeenCalledTimes(5);
const createdWeekDays = (scheduleRepo.create as jest.Mock).mock.calls
.map((call) => (call[0] as ClassSchedule).weekDay)
.sort((a, b) => a - b);
expect(createdWeekDays).toEqual([2, 3, 4, 5, 6]);
for (const call of (scheduleRepo.create as jest.Mock).mock.calls) {
expect(call[0]).toEqual(
expect.objectContaining({
rentalId: 1,
scheduleType: 'RENTAL',
startDate: '2026-09-01',
endDate: '2026-09-05',
status: 'active',
}),
);
}
});
});
describe('update()', () => {
@@ -352,17 +422,20 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-08-01',
endDate: '2099-04-30',
};
const existingSchedule = {
id: 50,
// 多日租赁已按周几展开为 7 行排课weekDay 1..7
const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({
id: 49 + weekDay,
rentalId: 1,
scheduleType: 'RENTAL',
classroomId: 1,
} as ClassSchedule;
weekDay,
status: 'active',
})) as ClassSchedule[];
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
scheduleRepo.find.mockResolvedValue(existingSchedules);
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
await service.update(1, dto);
@@ -387,6 +460,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
expect(scheduleRepo.delete).not.toHaveBeenCalled();
});
it('cancels RENTAL schedule rows for weekdays no longer covered after the rental is shortened', async () => {
const existingRental = {
id: 1,
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-09-01',
endDate: '2026-09-07',
status: 'active',
notes: '',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
classroom: { id: 1 } as Classroom,
} as ClassroomRental;
const shortenedRental = { ...existingRental, endDate: '2026-09-02' };
const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({
id: 49 + weekDay,
rentalId: 1,
scheduleType: 'RENTAL',
classroomId: 1,
weekDay,
status: 'active',
})) as ClassSchedule[];
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(shortenedRental);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.find.mockResolvedValue(existingSchedules);
const dto: UpdateRentalDto = { endDate: '2026-09-02' };
await service.update(1, dto);
// 2026-09-01(周二)=2、2026-09-02(周三)=3 保留为 active其余周几行置为 cancelled
expect(scheduleRepo.update).toHaveBeenCalledWith(
50,
expect.objectContaining({ status: 'cancelled' }),
);
expect(scheduleRepo.update).toHaveBeenCalledWith(
54,
expect.objectContaining({ status: 'cancelled' }),
);
expect(scheduleRepo.update).toHaveBeenCalledWith(
51,
expect.objectContaining({ status: 'active', weekDay: 2 }),
);
expect(scheduleRepo.update).toHaveBeenCalledWith(
52,
expect.objectContaining({ status: 'active', weekDay: 3 }),
);
});
it('deactivates the RENTAL schedule row when the rental is cancelled', async () => {
const rental = {
id: 1,
@@ -423,7 +545,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
} as ClassroomRental;
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
scheduleRepo.find.mockResolvedValue([{ id: 50, weekDay: 3, status: 'active' } as ClassSchedule]);
await service.end(1);
@@ -431,7 +553,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
1,
expect.objectContaining({ status: 'ended' }),
);
expect(scheduleRepo.update).toHaveBeenCalled();
expect(scheduleRepo.update).toHaveBeenCalledWith(
50,
expect.objectContaining({ status: 'cancelled' }),
);
});
it('rejects ending a future rental', async () => {
@@ -492,12 +617,18 @@ describe('ClassroomRentalsService — organization roles', () => {
.mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }),
} as any;
const scheduleRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (value) => ({ ...value, id: 100 })),
create: jest.fn((value) => value),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
manager: {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({ getRepository: jest.fn((entity: unknown) => (entity === ClassSchedule ? scheduleRepo : undefined)) }),
),
},
} as any;
const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo);

View File

@@ -1,9 +1,23 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities';
import {
ClassroomRental,
Classroom,
ClassSchedule,
ClassroomStatus,
ScheduleType,
} from '../entities';
import { ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import dayjs from '../common/dayjs';
import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time';
// TODO: matrix 格子整体暂保持 any统计过滤时用最小显式形状避免 unsafe any 访问
interface ScheduleMatrixCell {
scheduleType?: string;
status?: string;
[key: string]: unknown;
}
const COLOR_PALETTE = [
"#5B8FF9",
@@ -43,8 +57,9 @@ export class RentalScheduleService {
this.scheduleRepo.find({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
// ClassSchedule.status 实体类型为 string保留字面量
status: 'active',
scheduleType: ScheduleType.INTERNAL,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
@@ -85,8 +100,9 @@ export class RentalScheduleService {
const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId })
// ClassSchedule.status 实体类型为 string保留字面量
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
.andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate })
.getMany();
@@ -118,25 +134,21 @@ export class RentalScheduleService {
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return false;
const startUtc = this.toUtcDate(overlapStart);
const endUtc = this.toUtcDate(overlapEnd);
const startWeekDay = startUtc.getUTCDay() || 7;
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
return startUtc <= endUtc;
}
private toUtcDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
// YYYY-MM-DD 字典序即时间序weekDay 取中国日历星期1=周一 … 7=周日),
// 统一走 common/china-time避免本地时区 getters 造成偏移。
const startWeekDay = getWeekDayFromDateOnly(overlapStart);
const firstOccurrence = addDaysToDateOnly(
overlapStart,
(schedule.weekDay - startWeekDay + 7) % 7,
);
return firstOccurrence <= overlapEnd;
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
current.setUTCDate(current.getUTCDate() + 1);
let current = startDate;
while (current <= endDate) {
dates.add(current);
current = addDaysToDateOnly(current, 1);
}
}
@@ -150,13 +162,11 @@ export class RentalScheduleService {
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return;
const current = this.toUtcDate(overlapStart);
const end = this.toUtcDate(overlapEnd);
const startWeekDay = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
while (current <= end) {
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
current.setUTCDate(current.getUTCDate() + 7);
const startWeekDay = getWeekDayFromDateOnly(overlapStart);
let current = addDaysToDateOnly(overlapStart, (schedule.weekDay - startWeekDay + 7) % 7);
while (current <= overlapEnd) {
dates.add(current);
current = addDaysToDateOnly(current, 7);
}
}
@@ -180,6 +190,7 @@ export class RentalScheduleService {
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
// TODO: 收敛为显式类型matrix 格子含 scheduleType/status 等字段,涉及面较大暂保持 any
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
@@ -193,12 +204,9 @@ export class RentalScheduleService {
}
for (const rental of rentals) {
const start = new Date(rental.startDate);
const end = new Date(rental.endDate);
const monthStart = new Date(first);
const monthEnd = new Date(last);
const effStart = start < monthStart ? monthStart : start;
const effEnd = end > monthEnd ? monthEnd : end;
// YYYY-MM-DD 字典序即时间序,直接字符串比较,避免本地时区解析偏移
const effStart = rental.startDate > first ? rental.startDate : first;
const effEnd = rental.endDate < last ? rental.endDate : last;
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
@@ -208,11 +216,13 @@ export class RentalScheduleService {
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
});
}
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
const day = d.getDate();
if (!matrix[rental.classroomId]) continue;
let d = effStart;
while (d <= effEnd) {
const day = Number(d.slice(8, 10));
if (matrix[rental.classroomId]) {
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
scheduleType: ScheduleType.RENTAL,
status: rental.status,
rentalId: rental.id,
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
@@ -221,6 +231,8 @@ export class RentalScheduleService {
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
d = addDaysToDateOnly(d, 1);
}
}
@@ -229,26 +241,25 @@ export class RentalScheduleService {
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
// ClassSchedule.status 实体类型为 string保留字面量
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL })
.andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last })
.getMany();
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
);
const schedEnd = new Date(
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
);
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
const day = d.getDate();
if (!matrix[sched.classroomId]) continue;
const schedStart = sched.startDate > first ? sched.startDate : first;
const schedEnd = sched.endDate < last ? sched.endDate : last;
let d = schedStart;
while (d <= schedEnd) {
const dow = getWeekDayFromDateOnly(d);
const day = Number(d.slice(8, 10));
if (dow === sched.weekDay && matrix[sched.classroomId]) {
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleType: ScheduleType.INTERNAL,
// ClassSchedule.status 实体类型为 string保留字面量
status: 'active',
scheduleId: sched.id,
className: (sched.class as { name?: string } | null)?.name || '',
subject: sched.subject,
@@ -257,11 +268,17 @@ export class RentalScheduleService {
endTime: sched.endTime,
color: '#52c41a',
};
}
d = addDaysToDateOnly(d, 1);
}
}
// 统计
// 统计:只统计 RENTAL 且非 cancelled 的天数——内部排课覆盖格INTERNAL
// cancelled 行不计入租赁占用matrix 每格带 scheduleType/status按需过滤
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).length;
const rented = Object.values(matrix[cls.id]).filter((cell) => {
const typed = cell as ScheduleMatrixCell | undefined;
return typed?.scheduleType === ScheduleType.RENTAL && typed?.status !== 'cancelled';
}).length;
summary[cls.id].rentedDays = rented;
summary[cls.id].idleDays = lastDay - rented;
summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0;
@@ -299,38 +316,78 @@ export class RentalScheduleService {
async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
const weekDay = this.dateToWeekDay(rental.startDate);
let schedule = await this.scheduleRepo.findOne({
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
});
const data = {
classroomId: rental.classroomId,
classId: null,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: rental.startDate,
endDate: rental.endDate,
subject: `${name} 租赁`,
teacherId: null,
scheduleType: 'RENTAL',
rentalId: rental.id,
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
notes: rental.notes,
};
if (schedule) {
await this.scheduleRepo.update(schedule.id, data);
} else {
schedule = this.scheduleRepo.create(data);
await this.scheduleRepo.save(schedule);
// 已结束的租赁(状态为 ENDED或 ACTIVE 但 endDate 早于今天)与已取消一致,
// 不再写入/更新为 active 排课,统一映射为 cancelled。
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
const isInactive =
rental.status === ClassroomRentalStatus.CANCELLED ||
rental.status === ClassroomRentalStatus.ENDED ||
(rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today);
// 多日租赁:按 startDate..endDate 覆盖的每一天(用 addDaysToDateOnly 迭代)展开成周几,
// 每个工作日对应一条 RENTAL 排课weekDay 取当天星期1=周一 … 7=周日),
// 与 getSchedule/findConflicts 按 weekDay 匹配的语义一致。
const coveredWeekDays = new Set<number>();
for (
let day = rental.startDate;
day <= rental.endDate;
day = addDaysToDateOnly(day, 1)
) {
coveredWeekDays.add(this.dateToWeekDay(day));
}
// read-then-multiple-writecancel 旧行 / update 覆盖行 / insert 新行)放进同一事务,
// 任一写入失败则整体回滚,避免残留半同步状态。
await this.scheduleRepo.manager.transaction(async (manager) => {
// 事务内使用 manager.getRepository 获得绑定到该事务的排课仓库
const scheduleRepo = manager.getRepository(ClassSchedule);
const existingSchedules = await scheduleRepo.find({
where: { rentalId: rental.id, scheduleType: ScheduleType.RENTAL },
});
const existingByWeekDay = new Map<number, ClassSchedule>();
for (const schedule of existingSchedules) {
existingByWeekDay.set(schedule.weekDay, schedule);
}
// 租赁缩短/换教室后不再覆盖的周几,旧排课行统一映射为 cancelled避免残留旧占位
// ClassSchedule.status 实体类型为 string保留 'cancelled' 字面量)
for (const schedule of existingSchedules) {
if (!coveredWeekDays.has(schedule.weekDay) && schedule.status !== 'cancelled') {
await scheduleRepo.update(schedule.id, { status: 'cancelled' });
}
}
for (const weekDay of coveredWeekDays) {
const data = {
classroomId: rental.classroomId,
classId: null,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: rental.startDate,
endDate: rental.endDate,
subject: `${name} 租赁`,
teacherId: null,
scheduleType: ScheduleType.RENTAL,
rentalId: rental.id,
// ClassSchedule.status 实体类型为 string保留 'active'/'cancelled' 字面量
status: isInactive ? 'cancelled' : 'active',
notes: rental.notes,
};
const schedule = existingByWeekDay.get(weekDay);
if (schedule) {
await scheduleRepo.update(schedule.id, data);
} else {
const created = scheduleRepo.create(data);
await scheduleRepo.save(created);
}
}
});
}
dateToWeekDay(date: string): number {
const d = new Date(date);
const day = d.getDay();
return day === 0 ? 7 : day;
return getWeekDayFromDateOnly(date);
}
}

View File

@@ -0,0 +1,38 @@
import {
CHINA_UTC_OFFSET,
addDaysToDateOnly,
getWeekDayFromDateOnly,
isConsecutiveDates,
parseChinaDateOnly,
} from './china-time';
describe('china-time', () => {
it('exports UTC+8 offset', () => {
expect(CHINA_UTC_OFFSET).toBe(8);
});
it('addDaysToDateOnly crosses month/year boundaries', () => {
expect(addDaysToDateOnly('2026-02-28', 1)).toBe('2026-03-01');
expect(addDaysToDateOnly('2026-12-31', 1)).toBe('2027-01-01');
expect(addDaysToDateOnly('2026-01-01', -1)).toBe('2025-12-31');
expect(addDaysToDateOnly('2026-08-09', 7)).toBe('2026-08-16');
});
it('getWeekDayFromDateOnly is timezone independent', () => {
// 2026-08-09 是周日 → 7
expect(getWeekDayFromDateOnly('2026-08-09')).toBe(7);
// 2026-08-10 是周一 → 1
expect(getWeekDayFromDateOnly('2026-08-10')).toBe(1);
});
it('parseChinaDateOnly anchors to UTC+8 midnight', () => {
const d = parseChinaDateOnly('2026-08-09');
expect(d.toISOString()).toBe('2026-08-08T16:00:00.000Z');
});
it('isConsecutiveDates detects adjacent dates', () => {
expect(isConsecutiveDates('2026-08-09', '2026-08-10')).toBe(true);
expect(isConsecutiveDates('2026-08-09', '2026-08-11')).toBe(false);
expect(isConsecutiveDates('2026-08-09', '2026-08-09')).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import dayjs from './dayjs';
/**
* 中国时区Asia/Shanghai常量与日期工具。
* 服务端/生产统一按 UTC+8 处理「业务日期」,避免与服务器本地时区混用导致 off-by-one。
*/
export const CHINA_UTC_OFFSET = 8;
/** 当前时刻的中国日期YYYY-MM-DD。 */
export function getChinaDate(date: Date = new Date()): string {
return dayjs(date).utcOffset(CHINA_UTC_OFFSET).format('YYYY-MM-DD');
}
/** 把 'YYYY-MM-DD' 按 UTC+8 午夜解析为 Date用于需要绝对时刻的场合。 */
export function parseChinaDateOnly(dateOnly: string): Date {
return new Date(`${dateOnly}T00:00:00+08:00`);
}
/** 纯日期字符串加减天数,避免本地时区 getters 造成的偏移。 */
export function addDaysToDateOnly(dateOnly: string, days: number): string {
const [y, m, d] = dateOnly.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
const yy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
const dd = String(dt.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
/** 返回 dateOnly 在中国日历下的星期1=周一 … 7=周日),与本地时区无关。 */
export function getWeekDayFromDateOnly(dateOnly: string): number {
const [y, m, d] = dateOnly.split('-').map(Number);
const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return day === 0 ? 7 : day;
}
/** 两个 YYYY-MM-DD 是否按中国日历相邻(差 1 天)。 */
export function isConsecutiveDates(a: string, b: string): boolean {
return addDaysToDateOnly(a, 1) === b || addDaysToDateOnly(b, 1) === a;
}

View File

@@ -13,7 +13,11 @@ describe('DepositsService.purge', () => {
...overrides?.deposit,
};
const repo = {
findOne: jest.fn().mockResolvedValue(deposit),
// purge 现在按 id + 已归档状态查询无状态条件时fallback返回记录本体
findOne: jest.fn(async ({ where }: { where: { id?: number; status?: string } }) => {
if (where?.status === 'archived') return deposit.status === 'archived' ? deposit : null;
return deposit;
}),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
@@ -55,11 +59,18 @@ describe('DepositsService.purge', () => {
expect(repo.delete).not.toHaveBeenCalled();
});
it('does not delete when the deposit is no longer archived by delete time', async () => {
const { service, repo } = createService();
repo.delete.mockResolvedValue({ affected: 0 });
await expect(service.purge(1)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' });
});
it('deletes an archived deposit with no paid history', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除押金(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' });
});
});

View File

@@ -1,12 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Not, Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
import { escapeLike } from '../common/like-escape';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -195,7 +196,7 @@ export class DepositsService {
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
{ keyword: `%${escapeLike(query.keyword)}%` },
);
}
if (query?.status && query.status !== 'archived') {
@@ -236,7 +237,10 @@ export class DepositsService {
}
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
// 只匹配未归档押金:避免给学生新建押金时错误地复活/修改已归档记录
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, status: Not('archived') },
});
if (existing) {
existing.amount = money(Number(existing.amount || 0) + amount);
existing.paidDate = dto.paidDate;
@@ -326,10 +330,12 @@ export class DepositsService {
}
async purge(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'archived') {
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
// 明确按已归档状态查询:避免 findOne 匹配到被 create() 复活/修改的新押金记录
const deposit = await this.repo.findOne({ where: { id, status: 'archived' } });
if (!deposit) {
const existing = await this.repo.findOne({ where: { id } });
if (existing) throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
throw new NotFoundException('押金记录不存在');
}
if (Number(deposit.refundAmount || 0) > 0) {
throw new BadRequestException('该押金已有退款金额,无法永久删除');
@@ -343,7 +349,11 @@ export class DepositsService {
if (paidInstallments > 0) {
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
}
await this.repo.delete(id);
// 条件删除:即使读取后状态被并发修改(例如被 create() 复活),也只删除仍处于已归档的记录
const result = await this.repo.delete({ id, status: 'archived' });
if (!result.affected) {
throw new BadRequestException('押金状态已变化,请刷新后重试');
}
return { message: '已永久删除押金(不可恢复)' };
}

View File

@@ -24,22 +24,57 @@ export class Bill {
@Column({ name: 'period_end', type: 'date' })
periodEnd: string;
@Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
@Column({
name: 'shared_amount',
type: 'decimal',
precision: 10,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
sharedAmount: number;
@Column({ name: 'personal_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
@Column({
name: 'personal_amount',
type: 'decimal',
precision: 10,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
personalAmount: number;
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
@Column({
name: 'total_amount',
type: 'decimal',
precision: 10,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
totalAmount: number;
@Column({ type: 'varchar', length: 30, default: 'batch' })
source: 'batch' | 'student_utility';
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
@Column({
name: 'paid_amount',
type: 'decimal',
precision: 10,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
paidAmount: number;
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
@Column({
name: 'outstanding_amount',
type: 'decimal',
precision: 10,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
outstandingAmount: number;
@Column({ type: 'varchar', length: 20, default: 'unpaid' })

View File

@@ -26,7 +26,7 @@ export class ClassStudent {
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student)
@ManyToOne(() => Student, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' })
student: Student;

View File

@@ -57,11 +57,11 @@ export class DingAttendanceRaw {
matchStatus: string;
@Column({ name: 'matched_student_id', type: 'integer', nullable: true })
matchedStudentId: number;
matchedStudentId: number | null;
@ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'matched_student_id' })
matchedStudent: Student;
matchedStudent: Student | null;
@Column({ name: 'raw_data', type: 'text', nullable: true })
rawData: string;

View File

@@ -54,11 +54,11 @@ export class DingLeaveRaw {
matchStatus: string;
@Column({ name: 'matched_student_id', type: 'integer', nullable: true })
matchedStudentId: number;
matchedStudentId: number | null;
@ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'matched_student_id' })
matchedStudent: Student;
matchedStudent: Student | null;
@Column({ name: 'raw_data', type: 'text', nullable: true })
rawData: string;

View File

@@ -4,10 +4,7 @@ import {
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('learning_records')
export class LearningRecord {
@@ -17,10 +14,6 @@ export class LearningRecord {
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'record_date', type: 'date', nullable: true })
recordDate: string;

View File

@@ -4,10 +4,7 @@ import {
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('student_profiles')
export class StudentProfile {
@@ -17,10 +14,6 @@ export class StudentProfile {
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'target_college', length: 100, nullable: true })
targetCollege: string;

View File

@@ -17,7 +17,13 @@ export class StudentWallet {
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
@Column({
type: 'decimal',
precision: 12,
scale: 2,
default: 0,
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
})
balance: number;
@CreateDateColumn({ name: 'created_at' })

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { ExamsService } from './exams.service';
import { Exam } from '../entities';
describe('ExamsService.purge', () => {
const createService = (overrides?: { exam?: Record<string, unknown> }) => {
@@ -10,13 +11,23 @@ describe('ExamsService.purge', () => {
find: jest.fn().mockResolvedValue([exam]),
};
const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) };
const dataSource = {
transaction: jest.fn(async (cb: (manager: any) => unknown) =>
cb({
getRepository: jest.fn((entity: unknown) => {
if (entity === Exam) return examRepo;
throw new Error(`Unexpected repository: ${String(entity)}`);
}),
}),
),
};
const service = new ExamsService(
examRepo as never,
{} as never,
{} as never,
{} as never,
classTeacherRepo as never,
{} as never,
dataSource as never,
);
return { service, examRepo, classTeacherRepo };
};

View File

@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
import { ExamScore } from '../entities';
import { Exam, ExamScore } from '../entities';
import { QueryExamDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';
@@ -36,6 +36,16 @@ function updateQb(affected = 1) {
return qb;
}
function runWithExamRepo(examRepo: Record<string, jest.Mock>) {
return async (run: (manager: any) => Promise<unknown>) =>
run({
getRepository: jest.fn((entity: unknown) => {
if (entity === Exam) return examRepo;
throw new Error(`Unexpected repository: ${String(entity)}`);
}),
});
}
describe('ExamsService', () => {
it('creates score rows from the active class roster snapshot', async () => {
const members = [
@@ -260,7 +270,7 @@ describe('ExamsService', () => {
createQueryBuilder: jest.fn(() => qb),
};
const scoreRepo = { update: jest.fn(), save: jest.fn() };
const service = createService(async () => undefined, { examRepo, scoreRepo });
const service = createService(runWithExamRepo(examRepo), { examRepo, scoreRepo });
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
message: '已批量归档 1 场考试',
@@ -283,7 +293,7 @@ describe('ExamsService', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = createService(async () => undefined, { examRepo });
const service = createService(runWithExamRepo(examRepo), { examRepo });
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
message: '已批量恢复 1 场考试',

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities';
import { CreateExamDto, QueryExamDto } from './dto/exam.dto';
import { escapeLike } from '../common/like-escape';
@Injectable()
export class ExamsService {
@@ -31,7 +32,7 @@ export class ExamsService {
const where: Record<string, unknown> = {
status: query.isArchived ? 'archived' : 'active',
};
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
if (query.keyword) where.examName = Like(`%${escapeLike(query.keyword)}%`);
if (query.examType) where.examType = query.examType;
if (query.classId) where.classId = query.classId;
if (accessibleClassIds) {
@@ -205,27 +206,33 @@ export class ExamsService {
async batchPurge(ids: number[], userId: number, canManageAll: boolean) {
const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除');
const deleted: number[] = [];
const skipped: string[] = [];
for (const exam of exams) {
if (exam.status !== 'archived') {
skipped.push(`${exam.examName}(未归档)`);
continue;
// 循环内逐条删除放进同一事务:任一删除失败则整批回滚(全有或全无)。
return this.dataSource.transaction(async (manager) => {
const examRepo = manager.getRepository(Exam);
const deleted: number[] = [];
const skipped: string[] = [];
for (const exam of exams) {
if (exam.status !== 'archived') {
skipped.push(`${exam.examName}(未归档)`);
continue;
}
await examRepo.delete(exam.id);
deleted.push(exam.id);
}
await this.examRepo.delete(exam.id);
deleted.push(exam.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 场考试(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 场考试(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
});
}
async batchArchive(ids: number[], userId: number, canManageAll: boolean) {
const exams = await this.findBatchExams(ids, userId, canManageAll, '归档');
const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
const archived = await this.updateBatchStatus(targetIds, 'archived');
const archived = await this.dataSource.transaction(async (manager) =>
this.updateBatchStatus(manager, targetIds, 'archived'),
);
return {
message: `已批量归档 ${archived} 场考试`,
archived,
@@ -236,7 +243,9 @@ export class ExamsService {
async batchRestore(ids: number[], userId: number, canManageAll: boolean) {
const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复');
const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
const restored = await this.updateBatchStatus(targetIds, 'active');
const restored = await this.dataSource.transaction(async (manager) =>
this.updateBatchStatus(manager, targetIds, 'active'),
);
return {
message: `已批量恢复 ${restored} 场考试`,
restored,
@@ -263,9 +272,14 @@ export class ExamsService {
return exams;
}
private async updateBatchStatus(ids: number[], status: 'active' | 'archived') {
private async updateBatchStatus(
manager: EntityManager,
ids: number[],
status: 'active' | 'archived',
) {
if (ids.length === 0) return 0;
const result = await this.examRepo
const result = await manager
.getRepository(Exam)
.createQueryBuilder()
.update()
.set({ status })

View File

@@ -53,7 +53,11 @@ export class ExpenseTypesService {
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
const exists = await this.repo.findOne({ where: { code: normalized.code } });
// 注意ExpenseType.code 在数据库有唯一索引(全表唯一),唯一性检查必须与之一致,
// 否则软删行会让 DB 层抛重复键错误。软删后重建同名类型需另行放开唯一索引(需迁移)。
const exists = await this.repo.findOne({
where: { code: normalized.code },
});
if (exists) throw new ConflictException('费用类型代码已存在');
return this.repo.save(this.repo.create(normalized));
}

View File

@@ -17,7 +17,7 @@ export class FinancialOperationsService {
const existing = await this.repo.findOne({ where: { operationId } });
if (existing) {
if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作');
if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;
if (existing.status === 'completed' && existing.resultJson) return this.parseResult(existing.resultJson);
if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交');
}
@@ -28,21 +28,38 @@ export class FinancialOperationsService {
} catch (error) {
const concurrent = await this.repo.findOne({ where: { operationId } });
if (concurrent?.status === 'completed' && concurrent.resultJson) {
return JSON.parse(concurrent.resultJson) as T;
return this.parseResult(concurrent.resultJson);
}
throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });
}
} else {
// 失败重试认领条件状态更新affected=1 才算认领成功,
// 避免并发下多个请求同时把 failed 改成 running 后重复执行 work()
const claim = await this.repo
.createQueryBuilder()
.update(FinancialOperation)
.set({ status: 'running', errorMessage: null, resultJson: null })
.where('id = :id', { id: operation.id })
.andWhere(
"(status IN ('failed') OR (status = 'completed' AND resultJson IS NULL) OR (status = 'running' AND updatedAt < DATE_SUB(NOW(), INTERVAL 15 MINUTE)))",
)
.execute();
if (claim.affected !== 1) {
const concurrent = await this.repo.findOne({ where: { operationId } });
if (concurrent?.status === 'completed' && concurrent.resultJson) {
return this.parseResult(concurrent.resultJson);
}
throw new ConflictException('该操作正在处理中,请勿重复提交');
}
operation.status = 'running';
operation.errorMessage = null;
operation.resultJson = null;
await this.repo.save(operation);
}
try {
const result = await work();
operation.status = 'completed';
operation.resultJson = JSON.stringify(result);
operation.resultJson = JSON.stringify(result ?? null);
await this.repo.save(operation);
return result;
} catch (error) {
@@ -52,4 +69,13 @@ export class FinancialOperationsService {
throw error;
}
}
/** 解析缓存的结果 JSON损坏数据按失败处理而不是抛 500。 */
private parseResult<T>(resultJson: string | null | undefined): T {
try {
return JSON.parse(resultJson ?? 'null') as T;
} catch {
throw new ConflictException('该操作的结果数据已损坏,请重试');
}
}
}

View File

@@ -99,10 +99,28 @@ export class ImportCommitService {
}
}
run.status = 'committing';
step.status = 'committing';
await this.runs.save(run);
await this.steps.save(step);
// 原子抢占:只有 run/step 仍处于可提交状态时才迁移到 committing
// 避免两个并发请求同时通过校验并重复写入affected=0 表示已被他人抢占)。
await this.dataSource.transaction(async (manager) => {
const runClaim = await manager.update(
ImportRun,
{ id: run.id, status: In(['ready', 'preparing']) },
{ status: 'committing' },
);
if ((runClaim.affected ?? 0) !== 1) {
throw new ConflictException('导入任务正在提交中或状态已变化,请刷新后重试');
}
const stepClaim = await manager.update(
ImportStep,
{ id: step.id, status: 'ready' },
{ status: 'committing' },
);
if ((stepClaim.affected ?? 0) !== 1) {
throw new ConflictException(
`阶段「${IMPORT_STEP_LABELS[stepKey]}」正在提交中或状态已变化,请刷新后重试`,
);
}
});
const counts = { created: 0, updated: 0, skipped: 0, failed: 0 };
try {

View File

@@ -80,7 +80,6 @@ export class ImportPreviewService {
suggestMapping(sheetsData[0]?.headers ?? [], stepKey));
assertMapping(stepKey, mapping, sheetsData, usedSheets);
await this.rows.delete({ stepId: step.id });
const rowEntities: ImportRow[] = [];
const summary: StepPreviewSummary = {
total: 0,
@@ -153,12 +152,17 @@ export class ImportPreviewService {
}
}
await this.rows.save(rowEntities);
step.sheetsJson = JSON.stringify(usedSheets);
step.mappingJson = JSON.stringify(mapping);
step.status = 'ready';
step.summaryJson = JSON.stringify(summary);
await this.steps.save(step);
// 同一批次的写入(清空旧预览行、写新预览行、更新阶段状态)放进同一事务,
// 避免预览过程中失败留下半成品数据。
await this.dataSource.transaction(async (manager) => {
await manager.delete(ImportRow, { stepId: step.id });
await manager.save(ImportRow, rowEntities);
step.sheetsJson = JSON.stringify(usedSheets);
step.mappingJson = JSON.stringify(mapping);
step.status = 'ready';
step.summaryJson = JSON.stringify(summary);
await manager.save(ImportStep, step);
});
const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? [];
const rows = rowEntities.map((entity) => ({

View File

@@ -256,6 +256,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -316,6 +325,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -495,6 +513,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -559,6 +586,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -635,6 +671,15 @@ describe('ImportsService', () => {
}
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -696,6 +741,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -767,6 +821,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
@@ -838,6 +901,15 @@ describe('ImportsService', () => {
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
cb({
delete: rowsRepo.delete,
save: jest.fn(async (...args: unknown[]) => {
const value = args.length >= 2 ? args[1] : args[0];
return rowsRepo.save(value as never);
}),
}),
),
};
const service = new ImportsService(
makeRunsRepo(run) as never,

View File

@@ -54,6 +54,7 @@ function createCheckInManager(options?: {
update: jest.fn(),
};
const queryResults = [
options?.student ?? { id: 3, organizationId: 7 },
options?.existingOccupancy ?? null,
options?.room ?? { id: 2, capacity: 4, status: 'available' },
...(options?.bed !== undefined ? [options.bed] : []),
@@ -99,6 +100,7 @@ function createCheckOutManager(occupancy: Occupancy | null) {
createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)),
save: jest.fn(async (value) => value),
update: jest.fn(),
count: jest.fn().mockResolvedValue(0),
};
return manager;
}

View File

@@ -72,6 +72,16 @@ export class OccupanciesService {
async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
return this.dataSource.transaction(async (manager) => {
// 先锁学生行:对「不存在的入住记录」做 SELECT ... FOR UPDATE 无法防止
// 两个并发入住同时通过检查后插入重复记录;锁学生行可让同一学生的
// 并发入住串行化,后到者在拿到锁后能看到先到者已提交的入住记录。
const student = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Student, 'student')
.where('student.id = :studentId', { studentId: dto.studentId }),
).getOne();
if (!student) throw new NotFoundException('学生不存在');
const existing = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
@@ -91,9 +101,6 @@ export class OccupanciesService {
where: { roomId: dto.roomId, checkOutDate: IsNull() },
});
if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满');
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (dto.bedId) {
const bed = await this.withPessimisticWriteLock(
manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', {

View File

@@ -75,7 +75,14 @@ export class OccupancyOperationsService {
await manager.save(occ);
if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
await manager.update(Room, occ.roomId, { status: 'available' });
// 多床位房间不能因为单条退宿就置为 available
// 只有该房间不再有任何在住记录时才更新房间状态。
const remainingActive = await manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remainingActive === 0) {
await manager.update(Room, occ.roomId, { status: 'available' });
}
return occ;
});
}
@@ -113,7 +120,14 @@ export class OccupancyOperationsService {
if (oldOcc.lockerId) {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 多床位房间不能因为单次换房就置为 available
// 只有原房间不再有任何在住记录时才更新房间状态。
const oldRoomRemaining = await runner.manager.count(Occupancy, {
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
});
if (oldRoomRemaining === 0) {
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
}
// 检查新房容量
const newRoom = await withPessimisticWriteLock(
runner.manager
@@ -365,12 +379,18 @@ export class OccupancyOperationsService {
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
await runner.manager.save(occ);
// 更新房间状态
await runner.manager.update(Room, occ.roomId, { status: 'available' });
// 释放床位/柜子
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId)
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
// 多床位房间不能因为单条退宿就置为 available
// 只有该房间不再有任何在住记录时才更新房间状态。
const remainingActive = await runner.manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remainingActive === 0) {
await runner.manager.update(Room, occ.roomId, { status: 'available' });
}
success++;
}
await runner.commitTransaction();

View File

@@ -12,6 +12,9 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri
{ code: 'student:import', name: '导入学生', group: 'student' },
{ code: 'student:export', name: '导出学生', group: 'student' },
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
{ code: 'exam:create', name: '创建考试', group: 'exam' },
{ code: 'exam:edit', name: '编辑/归档考试与录入成绩', group: 'exam' },
{ code: 'exam:delete', name: '删除考试', group: 'exam' },
{ code: 'room:view', name: '查看宿舍', group: 'room' },
{ code: 'room:inspect', name: '宿舍查寝', group: 'room' },
{ code: 'room:create', name: '新增宿舍', group: 'room' },
@@ -104,9 +107,6 @@ export const DEPRECATED_PERMISSION_CODES = [
'learning:create',
'learning:edit',
'learning:delete',
'exam:create',
'exam:edit',
'exam:delete',
'department:view',
'department:edit',
'department:delete',

View File

@@ -39,17 +39,6 @@ export class RbacService {
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
}
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
const uniqueIds = [...new Set(permissionIds)];
const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];
if (permissions.length !== uniqueIds.length) {
const foundIds = new Set(permissions.map((permission) => permission.id));
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
throw new Error(`权限不存在: ${missingIds.join(',')}`);
}
return permissions;
}
async getTeacherWorkspace(userId: number) {
// Find all classes where this user is a teacher
const teacherAssignments = await this.classTeacherRepo.find({
@@ -139,11 +128,16 @@ export class RbacSeedService {
}
async seedData(): Promise<void> {
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
if (restoredLegacyUsers.affected) {
// 仅当角色表为空(首次初始化)或显式设置 SEED_ROLES=true 时才播种;
// 否则跳过,避免每次启动都无条件重激活角色/权限,覆盖管理员的自定义调整。
const forceSeed = process.env.SEED_ROLES === 'true';
const roleCount = await this.roleRepo.count();
if (roleCount > 0 && !forceSeed) {
this.logger.log(
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
`角色表已有 ${roleCount} 条记录,跳过种子数据重激活(如需强制播种请设置 SEED_ROLES=true`,
);
await this.ensureDefaultAdmin();
return;
}
for (const p of PRESET_PERMISSIONS) {
@@ -273,26 +267,40 @@ export class RbacSeedService {
}
}
const count = await this.userRepo.count();
if (count === 0) {
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const hash = await bcrypt.hash(adminPassword, 10);
const adminUser = this.userRepo.create({
username: 'admin',
passwordHash: hash,
name: '管理员',
});
const superAdminRole = allRoles.find((r) => r.code === 'super_admin');
if (superAdminRole) {
adminUser.roles = [superAdminRole];
}
await this.userRepo.save(adminUser);
this.logger.log(
`已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`,
);
}
await this.ensureDefaultAdmin();
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
}
/**
* 首次初始化时创建默认管理员。
* 即使跳过角色/权限播种(角色表已有数据)也会执行,避免出现「有角色但无管理员」的状态。
*/
private async ensureDefaultAdmin(): Promise<void> {
const count = await this.userRepo.count();
if (count > 0) return;
const adminPassword = process.env.ADMIN_PASSWORD;
// 生产环境禁止使用默认弱口令
if (!adminPassword) {
if (process.env.NODE_ENV === 'production') {
throw new Error('生产环境必须设置 ADMIN_PASSWORD 后再初始化默认管理员');
}
this.logger.warn('ADMIN_PASSWORD 未设置,开发环境使用默认密码 admin123');
}
const password = adminPassword || 'admin123';
const hash = await bcrypt.hash(password, 10);
const adminUser = this.userRepo.create({
username: 'admin',
passwordHash: hash,
name: '管理员',
});
const superAdminRole = await this.roleRepo.findOne({ where: { code: 'super_admin' } });
if (superAdminRole) {
adminUser.roles = [superAdminRole];
}
await this.userRepo.save(adminUser);
// 不打印密码,避免凭据落入日志
this.logger.log('已创建默认管理员: admin请妥善保管密码');
}
}

View File

@@ -28,6 +28,7 @@ describe('RbacService seedData', () => {
remove: jest.fn(async (value: any) => value),
};
const roleRepo = {
count: jest.fn(async () => 0),
findOne: jest.fn(async ({ where }: any) =>
where.code === 'system_admin' || where.name === '系统管理员' ? systemAdminRole : null,
),
@@ -99,6 +100,7 @@ describe('RbacService seedData', () => {
remove: jest.fn(async (value) => value),
};
const roleRepo = {
count: jest.fn(async () => 0),
findOne: jest.fn(async ({ where }: any) =>
where.code === 'teacher' || where.name === '老师' ? teacherRole : null,
),
@@ -187,6 +189,7 @@ describe('RbacService legacy role consolidation', () => {
remove: jest.fn(async (value) => value),
};
const roleRepo = {
count: jest.fn(async () => 0),
findOne: jest.fn(async () => targetRole),
create: jest.fn((value) => ({ ...value, permissions: [] })),
save: jest.fn(async (value) => value),
@@ -217,3 +220,71 @@ describe('RbacService legacy role consolidation', () => {
expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole);
});
});
describe('RbacSeedService startup gating', () => {
const makeService = (roleCount: number) => {
const permRepo = {
findOne: jest.fn(async () => null),
create: jest.fn(),
save: jest.fn(),
find: jest.fn(async () => []),
remove: jest.fn(),
};
const roleRepo = {
count: jest.fn(async () => roleCount),
findOne: jest.fn(async () => null),
create: jest.fn(),
save: jest.fn(),
find: jest.fn(async () => []),
remove: jest.fn(),
};
const userRepo = {
update: jest.fn(async () => ({ affected: 0 })),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
};
const service = new RbacService(
permRepo as never,
roleRepo as never,
userRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, permRepo, roleRepo, userRepo };
};
it('skips re-seeding (no reactivation) when roles exist and SEED_ROLES is not set', async () => {
const original = process.env.SEED_ROLES;
delete process.env.SEED_ROLES;
try {
const { service, permRepo, roleRepo, userRepo } = makeService(3);
await service.seedData();
expect(roleRepo.count).toHaveBeenCalled();
expect(permRepo.findOne).not.toHaveBeenCalled();
expect(roleRepo.save).not.toHaveBeenCalled();
expect(userRepo.update).not.toHaveBeenCalled();
} finally {
if (original === undefined) delete process.env.SEED_ROLES;
else process.env.SEED_ROLES = original;
}
});
it('seeds when SEED_ROLES=true even if roles already exist', async () => {
const original = process.env.SEED_ROLES;
process.env.SEED_ROLES = 'true';
try {
const { service, permRepo, roleRepo } = makeService(3);
await service.seedData();
expect(permRepo.findOne).toHaveBeenCalled();
expect(roleRepo.save).toHaveBeenCalled();
} finally {
if (original === undefined) delete process.env.SEED_ROLES;
else process.env.SEED_ROLES = original;
}
});
});

View File

@@ -92,17 +92,22 @@ export class RoomsService {
}
async create(dto: CreateRoomDto) {
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
const entity = this.repo.create({
...dto,
building: dto.building ?? parsed.building,
floor: dto.floor ?? parsed.floor,
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
// 宿舍 + 默认床位创建放进同一事务,避免宿舍已建、床位失败留下半成品。
return this.dataSource.transaction(async (manager) => {
const roomRepo = manager.getRepository(Room);
const bedRepo = manager.getRepository(Bed);
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
const entity = roomRepo.create({
...dto,
building: dto.building ?? parsed.building,
floor: dto.floor ?? parsed.floor,
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
});
const room = await roomRepo.save(entity);
await this.createDefaultBedsWithManager(bedRepo, room.id, room.capacity);
return room;
});
const room = await this.repo.save(entity);
await this.createDefaultBeds(room.id, room.capacity);
return room;
}
async update(id: number, dto: UpdateRoomDto) {
@@ -311,6 +316,19 @@ export class RoomsService {
return this.queries.createDefaultBeds(roomId, capacity);
}
private async createDefaultBedsWithManager(
bedRepo: Repository<Bed>,
roomId: number,
capacity: number,
): Promise<void> {
const count = Math.max(capacity ?? 0, 0);
if (count === 0) return;
const beds = Array.from({ length: count }, (_, index) =>
bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
);
await bedRepo.save(beds);
}
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
return this.bedOps.getNextBedNumber(beds);
}

View File

@@ -0,0 +1,558 @@
import { BadRequestException } from '@nestjs/common';
import { EntityManager, Repository } from 'typeorm';
import { StudentsImportService } from './students.import.service';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { Organization } from '../entities/organization.entity';
describe('StudentsImportService.batchImport', () => {
const makeManager = (options?: {
existingByPhone?: Record<string, Partial<Student>>;
existingByIdNumber?: Record<string, Partial<Student>>;
hostOrganization?: Organization | null;
throwOnOrganization?: boolean;
}): {
manager: EntityManager;
studentRepo: Repository<Student>;
organizationRepo: Repository<Organization>;
archiveRepo: Record<string, jest.Mock>;
savedProfiles: any[];
savedEnrollments: any[];
savedExamScores: any[];
savedLearningRecords: any[];
} => {
const existingByPhone = options?.existingByPhone ?? {};
const existingByIdNumber = options?.existingByIdNumber ?? {};
const savedProfiles: any[] = [];
const savedEnrollments: any[] = [];
const savedExamScores: any[] = [];
const savedLearningRecords: any[] = [];
let nextStudentId = 1;
const inValues = (value: unknown): string[] => {
if (Array.isArray(value)) return value as string[];
if (value && typeof value === 'object' && '_value' in value) {
return (value as { _value: unknown })._value as string[];
}
return [];
};
const studentRepo = {
// 批量预取后不再逐行调用 findOne保留 mock 用于断言已被替代
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn(async ({ where }: any) => {
if (where?.phone) {
return inValues(where.phone)
.map((phone) => existingByPhone[phone])
.filter((student): student is Student => Boolean(student));
}
if (where?.idNumber) {
return inValues(where.idNumber)
.map((idNumber) => existingByIdNumber[idNumber])
.filter((student): student is Student => Boolean(student));
}
return [];
}),
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: any) => {
const id = value?.id ?? nextStudentId++;
return { ...value, id };
}),
update: jest.fn(),
} as unknown as Repository<Student>;
const organizationRepo = {
findOne: jest.fn(async () => {
if (options?.throwOnOrganization) throw new BadRequestException('尚未配置本机构');
return options?.hostOrganization ?? { id: 7, isHost: true, status: 'active' };
}),
} as unknown as Repository<Organization>;
const archiveRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: any) => {
savedProfiles.push(value);
return { ...value, id: value?.id ?? 10 };
}),
};
const manager = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return studentRepo;
if (entity === Organization) return organizationRepo;
if (entity === StudentProfile) return archiveRepo;
if (entity === ResultArchive) return archiveRepo;
if (entity === StudentEnrollment) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedEnrollments.push(v);
return { ...v, id: v?.id ?? 20 };
}),
};
}
if (entity === ExamScore) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedExamScores.push(v);
return { ...v, id: v?.id ?? 30 };
}),
};
}
if (entity === LearningRecord) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedLearningRecords.push(v);
return { ...v, id: v?.id ?? 40 };
}),
};
}
throw new Error(`Unexpected entity ${String(entity)}`);
}),
} as unknown as EntityManager;
return {
manager,
studentRepo,
organizationRepo,
archiveRepo,
savedProfiles,
savedEnrollments,
savedExamScores,
savedLearningRecords,
};
};
const createService = (options?: Parameters<typeof makeManager>[0]) => {
const mocks = makeManager(options);
const repo = {
manager: {
transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(mocks.manager)),
},
} as unknown as Repository<Student>;
const service = new StudentsImportService(repo);
return { service, repo, ...mocks };
};
it('imports students inside one transaction and returns the existing shape', async () => {
const { service, repo } = createService();
const result = await service.batchImport([
{ name: '张三', phone: '13800138000' },
{ name: '李四', phone: '13900139000' },
]);
expect(result).toEqual({
message: '成功导入 2 名学生,导入档案相关记录 0 条,跳过 0 条(重复或空行)',
imported: 2,
archiveImported: 0,
skipped: 0,
invalidDateSkipped: 0,
});
expect(repo.manager.transaction).toHaveBeenCalledTimes(1);
});
it('skips duplicate and empty rows without aborting the batch', async () => {
const { service } = createService({
existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student },
});
const result = await service.batchImport([
{ name: '张三', phone: '13800138000' },
{ name: '', phone: '13900139000' },
{ name: '王五', phone: '13700137000' },
]);
expect(result).toEqual({
message: '成功导入 1 名学生,导入档案相关记录 0 条,跳过 2 条(重复或空行)',
imported: 1,
archiveImported: 0,
skipped: 2,
invalidDateSkipped: 0,
});
});
it('keeps fail-fast semantics: a row failure rejects and rolls the batch back', async () => {
const { service, repo, manager } = createService({ throwOnOrganization: true });
await expect(
service.batchImport([{ name: '张三', phone: '13800138000' }]),
).rejects.toBeInstanceOf(BadRequestException);
// The transaction callback threw, so nothing was committed.
expect(repo.manager.transaction).toHaveBeenCalledTimes(1);
expect(manager.getRepository).toHaveBeenCalledWith(Student);
});
it('queries the host organization only once across the whole batch', async () => {
const { service, organizationRepo } = createService();
const result = await service.batchImport([
{ name: '张三', phone: '13800138000' },
{ name: '李四', phone: '13900139000' },
{ name: '王五', phone: '13700137000' },
]);
expect(result.imported).toBe(3);
expect(organizationRepo.findOne).toHaveBeenCalledTimes(1);
});
it('routes archive rows to the right student by phone', async () => {
const { service, savedEnrollments, savedExamScores, savedLearningRecords } = createService();
const result = await service.batchImport({
students: [
{ name: '张三', phone: '13800138000' },
{ name: '李四', phone: '13900139000' },
],
enrollments: [
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
{ phone: '13900139000', courseCategory: 'art', classType: 'small_class', className: '周末班' },
],
examScores: [
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90 },
{ phone: '13900139000', examType: 'final', subject: '数学', score: 88 },
],
learningRecords: [
{ phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: 'ok' },
{ phone: '13900139000', recordDate: '2024-10-21', recordType: 'study_feedback', content: 'good' },
],
});
expect(result.imported).toBe(2);
expect(result.archiveImported).toBe(6);
expect(savedEnrollments.map((e) => e.studentId)).toEqual([1, 2]);
expect(savedExamScores.map((e) => e.studentId)).toEqual([1, 2]);
expect(savedLearningRecords.map((e) => e.studentId)).toEqual([1, 2]);
});
it('deduplicates identical enrollment rows within one import (in-memory upsert)', async () => {
const { service, savedEnrollments } = createService();
await service.batchImport({
students: [{ name: '张三', phone: '13800138000' }],
enrollments: [
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
],
examScores: [],
learningRecords: [],
});
// 同一行被命中两次并更新保存与原有“find 后 save”的 upsert 语义一致),
// 但始终落在同一条记录上,不会新建重复行。
expect(savedEnrollments).toHaveLength(2);
expect(new Set(savedEnrollments.map((e) => e.id)).size).toBe(1);
});
it('skips invalid dates, records the count, and still imports the rest of the row', async () => {
const { service, savedProfiles } = createService();
const result = await service.batchImport({
students: [
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024/9/1' },
],
enrollments: [],
examScores: [
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: 'not-a-date' },
],
learningRecords: [
{ phone: '13800138000', recordDate: '2024/10/20', recordType: 'study_feedback', content: 'ok' },
],
});
// profile + exam score imported非法日期字段被跳过learning record 因必填日期非法整条跳过
expect(result.imported).toBe(1);
expect(result.archiveImported).toBe(2);
expect(result.invalidDateSkipped).toBe(3);
expect(savedProfiles[0]).not.toHaveProperty('profileDate');
});
it('treats impossible calendar dates as invalid while accepting real leap days', async () => {
const { service, savedProfiles, savedExamScores } = createService();
const result = await service.batchImport({
students: [
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-02-31' },
],
enrollments: [],
examScores: [
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2023-02-29' },
{ phone: '13800138000', examType: 'final', subject: '数学', score: 88, examDate: '2024-02-29' },
],
learningRecords: [],
});
// 2024-02-31 非法2 月没有 31 号2023 非闰年 2/29 非法2024 闰年 2/29 合法
expect(result.invalidDateSkipped).toBe(2);
expect(savedProfiles[0]).not.toHaveProperty('profileDate');
expect(savedExamScores[0].examDate).toBeUndefined();
expect(savedExamScores[1].examDate).toBe('2024-02-29');
});
it('writes well-formed dates unchanged', async () => {
const { service, savedProfiles } = createService();
await service.batchImport({
students: [
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-09-01' },
],
enrollments: [],
examScores: [
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2024-10-15' },
],
learningRecords: [
{ phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: '状态稳定' },
],
});
expect(savedProfiles[0].profileDate).toBe('2024-09-01');
});
it('prefetches existing students with two IN queries instead of per-row findOne', async () => {
const { service, studentRepo } = createService({
existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student },
existingByIdNumber: {
'110101199001011234': { id: 2, name: '李四', idNumber: '110101199001011234' } as Student,
},
});
const result = await service.batchImport([
{ name: '张三', phone: '13800138000' },
{ name: '李四', idNumber: '110101199001011234' },
{ name: '王五', phone: '13900139000' },
]);
// 批量预取phone + idNumber 各一次 IN 查询,不再逐行 findOne
expect(studentRepo.find).toHaveBeenCalledTimes(2);
expect(studentRepo.findOne).not.toHaveBeenCalled();
expect(result.imported).toBe(1);
expect(result.skipped).toBe(2);
});
it('keeps in-batch dedup by phone/idNumber after saving a new student', async () => {
const { service, studentRepo } = createService();
const result = await service.batchImport([
{ name: '张三', phone: '13800138000' },
{ name: '张三副本', phone: '13800138000' },
{ name: '李四', phone: '13900139000', idNumber: '110101199001011234' },
{ name: '李四副本', idNumber: '110101199001011234' },
]);
// 新保存学生同步进内存索引:后续相同 phone/idNumber 行视为重复跳过
expect(result.imported).toBe(2);
expect(result.skipped).toBe(2);
expect(studentRepo.findOne).not.toHaveBeenCalled();
});
});
describe('StudentsImportService.matchImport', () => {
const createService = (options?: {
existingByPhone?: Record<string, Partial<Student>>;
existingByIdNumber?: Record<string, Partial<Student>>;
}) => {
const existingByPhone = options?.existingByPhone ?? {};
const existingByIdNumber = options?.existingByIdNumber ?? {};
const savedProfiles: any[] = [];
const savedEnrollments: any[] = [];
const savedExamScores: any[] = [];
const savedLearningRecords: any[] = [];
const inValues = (value: unknown): string[] => {
if (Array.isArray(value)) return value as string[];
if (value && typeof value === 'object' && '_value' in value) {
return (value as { _value: unknown })._value as string[];
}
return [];
};
const studentRepo = {
// 批量预取后不再逐行调用 findOne保留 mock 用于断言已被替代
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn(async ({ where }: any) => {
if (where?.phone) {
return inValues(where.phone)
.map((phone) => existingByPhone[phone])
.filter((student): student is Student => Boolean(student));
}
if (where?.idNumber) {
return inValues(where.idNumber)
.map((idNumber) => existingByIdNumber[idNumber])
.filter((student): student is Student => Boolean(student));
}
return [];
}),
update: jest.fn(),
} as unknown as Repository<Student>;
const archiveRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: any) => {
savedProfiles.push(value);
return { ...value, id: value?.id ?? 10 };
}),
};
const manager = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return studentRepo;
if (entity === StudentProfile) return archiveRepo;
if (entity === ResultArchive) return archiveRepo;
if (entity === StudentEnrollment) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedEnrollments.push(v);
return { ...v, id: v?.id ?? 20 };
}),
};
}
if (entity === ExamScore) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedExamScores.push(v);
return { ...v, id: v?.id ?? 30 };
}),
};
}
if (entity === LearningRecord) {
return {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((v: unknown) => v),
save: jest.fn(async (v: any) => {
savedLearningRecords.push(v);
return { ...v, id: v?.id ?? 40 };
}),
};
}
throw new Error(`Unexpected entity ${String(entity)}`);
}),
} as unknown as EntityManager;
const repo = {
manager: {
transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(manager)),
},
} as unknown as Repository<Student>;
const service = new StudentsImportService(repo);
return { service, studentRepo, savedProfiles, savedEnrollments, savedExamScores, savedLearningRecords };
};
it('updates a matched student with writable fields', async () => {
const { service, studentRepo } = createService({
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
});
const result = await service.matchImport([
{ phone: '13800138000', name: '张三', gender: '男' },
]);
expect(studentRepo.update).toHaveBeenCalledWith(1, { name: '张三', gender: '男' });
expect(result).toEqual({
message: '更新已有学生资料 1 人,导入档案相关记录 0 条,跳过 0 条(无匹配/无更新字段/双键冲突)',
matched: 1,
archiveImported: 0,
skipped: 0,
skippedNoFields: 0,
skippedConflict: 0,
invalidDateSkipped: 0,
});
});
it('skips matched rows without writable fields instead of calling an empty update', async () => {
const { service, studentRepo } = createService({
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
});
const result = await service.matchImport([{ phone: '13800138000' }]);
expect(studentRepo.update).not.toHaveBeenCalled();
expect(result).toEqual({
message: '更新已有学生资料 0 人,导入档案相关记录 0 条,跳过 1 条(无匹配/无更新字段/双键冲突)',
matched: 0,
archiveImported: 0,
skipped: 1,
skippedNoFields: 1,
skippedConflict: 0,
invalidDateSkipped: 0,
});
});
it('skips rows whose phone and idNumber match different students (conflict)', async () => {
const { service, studentRepo } = createService({
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
existingByIdNumber: { '440111200001010011': { id: 2, idNumber: '440111200001010011' } as Student },
});
const result = await service.matchImport([
{ phone: '13800138000', idNumber: '440111200001010011', name: '张三' },
]);
// 双键命中不同学生:不得更新任何一方,行计入 skippedConflict
expect(studentRepo.update).not.toHaveBeenCalled();
expect(result.skipped).toBe(1);
expect(result.skippedConflict).toBe(1);
expect(result.matched).toBe(0);
});
it('matches by idNumber and can still update phone when it differs', async () => {
const { service, studentRepo } = createService({
existingByIdNumber: { '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student },
});
const result = await service.matchImport([
{ idNumber: '110101199001011234', phone: '13900139000' },
]);
expect(studentRepo.update).toHaveBeenCalledWith(1, { phone: '13900139000' });
expect(result.matched).toBe(1);
expect(result.skippedNoFields).toBe(0);
});
it('imports archive rows for a matched student even when the student row has no fields', async () => {
const { service, studentRepo, savedEnrollments } = createService({
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
});
const result = await service.matchImport({
students: [{ phone: '13800138000' }],
enrollments: [
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
],
examScores: [],
learningRecords: [],
});
expect(studentRepo.update).not.toHaveBeenCalled();
expect(savedEnrollments).toHaveLength(1);
expect(result.matched).toBe(1);
expect(result.archiveImported).toBe(1);
expect(result.skipped).toBe(0);
expect(result.skippedNoFields).toBe(0);
});
it('prefetches existing students with two IN queries instead of per-row findOne', async () => {
const { service, studentRepo } = createService({
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
existingByIdNumber: {
'110101199001011234': { id: 2, idNumber: '110101199001011234' } as Student,
},
});
const result = await service.matchImport([
{ phone: '13800138000', name: '张三' },
{ idNumber: '110101199001011234', name: '李四' },
{ phone: '13900139000', name: '王五' },
]);
// 批量预取phone + idNumber 各一次 IN 查询,不再逐行 findOne
expect(studentRepo.find).toHaveBeenCalledTimes(2);
expect(studentRepo.findOne).not.toHaveBeenCalled();
expect(result.matched).toBe(2);
expect(result.skipped).toBe(1);
});
it('keeps matching by updated phone within the same batch', async () => {
const { service, studentRepo } = createService({
existingByIdNumber: {
'110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student,
},
});
const result = await service.matchImport([
{ idNumber: '110101199001011234', phone: '13900139000', name: '张三' },
{ phone: '13900139000', name: '张三2' },
]);
// 第一行通过 idNumber 命中并把 phone 改为 13900139000
// 第二行按更新后的 phone 仍能命中同一学生(与同一事务内 findOne 语义一致)
expect(studentRepo.update).toHaveBeenCalledTimes(2);
expect(result.matched).toBe(2);
expect(result.skipped).toBe(0);
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, In, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
@@ -16,115 +16,296 @@ import type {
StudentWorkbookImport,
} from './student-import';
import { getHostOrganizationId } from './students.organization';
import { addDaysToDateOnly } from '../common/china-time';
/** 学生查重/匹配的内存索引phone 与 idNumber 各一张 Mapkey 为去空白后的值)。 */
type StudentLookupIndex = {
byPhone: Map<string, Student>;
byIdNumber: Map<string, Student>;
};
@Injectable()
export class StudentsImportService {
// 档案/报读/成绩等写入统一走 manager.getRepository见下方 helpers
// 这里只注入 Student repo查重、更新、开启事务
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(StudentProfile) private readonly profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment)
private readonly enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private readonly examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord)
private readonly learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private readonly resultRepo: Repository<ResultArchive>,
@InjectRepository(Organization) private readonly organizationRepo: Repository<Organization>,
) {}
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let imported = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
// 整批导入放进同一事务:查重、建学生、写档案要么全部成功,要么全部回滚。
return this.repo.manager.transaction(async (manager) => {
const studentRepo = manager.getRepository(Student);
const organizationRepo = manager.getRepository(Organization);
// 按手机号预建报读/成绩/回访行分组(手机号是导入行与学生档案之间的关联键,
// 等价于按学生分组),避免逐学生循环内对 data.* 全量 filter。
const enrollmentsByPhone = this.groupByPhone(data.enrollments);
const examScoresByPhone = this.groupByPhone(data.examScores);
const learningRecordsByPhone = this.groupByPhone(data.learningRecords);
// 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询),
// 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。
const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students);
let imported = 0;
let skipped = 0;
let archiveImported = 0;
let invalidDateSkipped = 0;
// 本机构 id 循环外懒加载缓存一次,避免每个学生行都查询
let hostOrganizationId: number | undefined;
for (const row of data.students) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
// 按 phone/idNumber 查重:先 phone 后 idNumber命中即视为已存在
if (this.matchStudentByIndexes(existingIndexes, row)) {
skipped++;
continue;
}
if (!row.organizationId && hostOrganizationId === undefined) {
hostOrganizationId = await getHostOrganizationId(organizationRepo);
}
const student = await studentRepo.save(
studentRepo.create({
name: row.name.trim(),
studentNo: row.studentNo?.trim() || undefined,
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || hostOrganizationId,
}),
);
// 新保存的学生同步进内存索引:本批后续相同 phone/idNumber 行视为重复跳过,
// 保持与逐行 findOne 一致的 upsert/去重语义。
this.indexStudent(existingIndexes, student);
const archive = await this.importArchiveData(manager, student.id, row, {
enrollments: enrollmentsByPhone,
examScores: examScoresByPhone,
learningRecords: learningRecordsByPhone,
});
archiveImported += archive.imported;
invalidDateSkipped += archive.invalidDateSkipped;
imported++;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
skipped++;
continue;
}
const student = await this.repo.save(
this.repo.create({
name: row.name.trim(),
studentNo: row.studentNo?.trim() || undefined,
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)),
}),
);
archiveImported += await this.importArchiveData(student.id, row, data);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
imported,
archiveImported,
skipped,
};
return {
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
imported,
archiveImported,
skipped,
invalidDateSkipped,
};
});
}
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let matched = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
// Match by phone first, then idNumber
let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
: null;
if (!student && row.idNumber?.trim()) {
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
// 整批匹配写入放进同一事务:更新学生资料与写档案要么全部成功,要么全部回滚。
return this.repo.manager.transaction(async (manager) => {
const studentRepo = manager.getRepository(Student);
// 与 batchImport 一致:按手机号预建分组,避免逐学生循环内全量 filter。
const enrollmentsByPhone = this.groupByPhone(data.enrollments);
const examScoresByPhone = this.groupByPhone(data.examScores);
const learningRecordsByPhone = this.groupByPhone(data.learningRecords);
// 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询),
// 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。
const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students);
let matched = 0;
let skipped = 0;
let skippedNoFields = 0;
let skippedConflict = 0;
let archiveImported = 0;
let invalidDateSkipped = 0;
for (const row of data.students) {
// 双键命中不同学生:跳过该行,避免把 idNumber 写到错误的 student 上
if (this.hasIndexConflict(existingIndexes, row)) {
skipped++;
skippedConflict++;
continue;
}
// Match by phone first, then idNumber
const student = this.matchStudentByIndexes(existingIndexes, row);
if (!student) {
skipped++;
continue;
}
const updates: Partial<
Pick<
Student,
| 'name'
| 'studentNo'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'supervisor'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
// phone/idNumber 是匹配键:与命中学生一致时视为无变更(避免空 set 更新),
// 不一致时仍允许通过另一个键命中后更新。
if (row.phone?.trim() && row.phone.trim() !== student.phone) updates.phone = row.phone.trim();
if (row.idNumber?.trim() && row.idNumber.trim() !== student.idNumber) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
// 按 phone/idNumber 命中已有学生但本行没有任何可写字段时,
// 跳过该学生的更新(避免 TypeORM 对空 set 报错),保留匹配/去重语义。
const hasWritableFields = Object.keys(updates).length > 0;
const archive = await this.importArchiveData(manager, student.id, row, {
enrollments: enrollmentsByPhone,
examScores: examScoresByPhone,
learningRecords: learningRecordsByPhone,
});
archiveImported += archive.imported;
invalidDateSkipped += archive.invalidDateSkipped;
if (!hasWritableFields && archive.imported === 0 && archive.invalidDateSkipped === 0) {
// 整行无任何可写字段:计入 skipped不抛错
skippedNoFields++;
skipped++;
continue;
}
if (hasWritableFields) {
await studentRepo.update(student.id, updates);
// 更新后的 phone/idNumber 同步进内存索引:后续行按新值仍可命中,
// 与逐行 findOne 在同一事务内能看到本批已更新记录的语义一致。
this.reindexStudent(existingIndexes, student, updates);
}
matched++;
}
if (!student) {
skipped++;
continue;
}
const updates: Partial<
Pick<
Student,
| 'name'
| 'studentNo'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'supervisor'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
if (row.phone?.trim()) updates.phone = row.phone.trim();
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates);
archiveImported += await this.importArchiveData(student.id, row, data);
matched++;
}
return {
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配/无更新字段/双键冲突)`,
matched,
archiveImported,
skipped,
skippedNoFields,
skippedConflict,
invalidDateSkipped,
};
});
}
/**
* 批量预取本批导入行涉及的所有 phone / idNumber 对应的已有学生:
* 只发两条 IN 查询phone In(...)、idNumber In(...)),内存建索引供逐行匹配,
* 替代逐行 findOneN+1。某类 key 为空时跳过对应查询。
*/
private async prefetchStudentIndexes(
studentRepo: Repository<Student>,
rows: StudentImportRow[],
): Promise<StudentLookupIndex> {
const phones = [
...new Set(
rows
.map((row) => row.phone?.trim())
.filter((phone): phone is string => Boolean(phone)),
),
];
const idNumbers = [
...new Set(
rows
.map((row) => row.idNumber?.trim())
.filter((idNumber): idNumber is string => Boolean(idNumber)),
),
];
const [byPhoneList, byIdNumberList] = await Promise.all([
phones.length > 0
? studentRepo.find({ where: { phone: In(phones) } })
: Promise.resolve([] as Student[]),
idNumbers.length > 0
? studentRepo.find({ where: { idNumber: In(idNumbers) } })
: Promise.resolve([] as Student[]),
]);
return {
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
matched,
archiveImported,
skipped,
byPhone: this.buildIndex(byPhoneList, (student) => student.phone),
byIdNumber: this.buildIndex(byIdNumberList, (student) => student.idNumber),
};
}
/** 从查询结果构建 phone / idNumber → Student 的 Mapkey 去空白,空值不建索引)。 */
private buildIndex(
students: Student[],
keyOf: (student: Student) => string | null | undefined,
): Map<string, Student> {
const index = new Map<string, Student>();
for (const student of students) {
const key = keyOf(student)?.trim();
if (key) index.set(key, student);
}
return index;
}
/**
* 双键冲突检测:同一行 phone 与 idNumber 分别命中不同的学生时,不能更新任何一个,
* 否则会把一个学生的 idNumber 写到另一个学生身上(数据错乱)。冲突行按 skipped 处理。
*/
private hasIndexConflict(indexes: StudentLookupIndex, row: StudentImportRow): boolean {
const phone = row.phone?.trim();
const idNumber = row.idNumber?.trim();
if (!phone || !idNumber) return false;
const byPhone = indexes.byPhone.get(phone);
const byIdNumber = indexes.byIdNumber.get(idNumber);
return !!byPhone && !!byIdNumber && byPhone.id !== byIdNumber.id;
}
/** 逐行匹配已有学生:优先 phone未命中再按 idNumber与 matchImport 原有语义一致)。 */
private matchStudentByIndexes(
indexes: StudentLookupIndex,
row: StudentImportRow,
): Student | undefined {
const phone = row.phone?.trim();
if (phone) {
const byPhone = indexes.byPhone.get(phone);
if (byPhone) return byPhone;
}
const idNumber = row.idNumber?.trim();
if (idNumber) {
const byIdNumber = indexes.byIdNumber.get(idNumber);
if (byIdNumber) return byIdNumber;
}
return undefined;
}
/** 新保存的学生同步进内存索引batchImport 批次内去重)。 */
private indexStudent(indexes: StudentLookupIndex, student: Student): void {
if (student.phone) indexes.byPhone.set(student.phone.trim(), student);
if (student.idNumber) indexes.byIdNumber.set(student.idNumber.trim(), student);
}
/**
* matchImport 更新学生资料后同步内存索引phone/idNumber 被改写时删除旧 key、
* 注册新 key并让内存实体与已更新值保持一致后续行按新值仍可命中
*/
private reindexStudent(
indexes: StudentLookupIndex,
student: Student,
updates: Partial<Pick<Student, 'phone' | 'idNumber'>>,
): void {
const oldPhone = student.phone?.trim();
const newPhone = updates.phone?.trim();
const oldIdNumber = student.idNumber?.trim();
const newIdNumber = updates.idNumber?.trim();
Object.assign(student, updates);
if (newPhone && newPhone !== oldPhone) {
if (oldPhone) indexes.byPhone.delete(oldPhone);
indexes.byPhone.set(newPhone, student);
}
if (newIdNumber && newIdNumber !== oldIdNumber) {
if (oldIdNumber) indexes.byIdNumber.delete(oldIdNumber);
indexes.byIdNumber.set(newIdNumber, student);
}
}
private normalizeImportData(
importData: StudentWorkbookImport | StudentImportRow[],
): StudentWorkbookImport {
@@ -142,6 +323,30 @@ export class StudentsImportService {
return String(left ?? '').trim() === String(right ?? '').trim();
}
/** 按手机号预建行分组(手机号去空白后作为 key空手机号的行不参与匹配。 */
private groupByPhone<T extends { phone?: string }>(rows: T[]): Map<string, T[]> {
const byPhone = new Map<string, T[]>();
for (const row of rows) {
const phone = this.normalizePhone(row.phone);
if (!phone) continue;
const list = byPhone.get(phone);
if (list) list.push(row);
else byPhone.set(phone, [row]);
}
return byPhone;
}
/**
* 日期字段写入前的真实日历校验:先检查 YYYY-MM-DD 形状,
* 再用 common/china-time 的 addDaysToDateOnly 往返判断月/日是否真实存在
* Date.UTC 会把溢出日期归一化,如 2024-02-31 → 2024-03-02往返不一致即非法
*/
private isValidDateOnly(value: string): boolean {
const dateOnly = value.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false;
return addDaysToDateOnly(dateOnly, 0) === dateOnly;
}
private hasProfileData(row: StudentImportRow) {
return [
row.targetCollege,
@@ -166,89 +371,161 @@ export class StudentsImportService {
}
private async importArchiveData(
manager: EntityManager,
studentId: number,
row: StudentImportRow,
data: StudentWorkbookImport,
) {
data: {
enrollments: Map<string, StudentEnrollmentImportRow[]>;
examScores: Map<string, ExamScoreImportRow[]>;
learningRecords: Map<string, LearningRecordImportRow[]>;
},
): Promise<{ imported: number; invalidDateSkipped: number }> {
const phone = this.normalizePhone(row.phone);
let imported = 0;
let invalidDateSkipped = 0;
if (this.hasProfileData(row)) {
await this.upsertProfileFromImport(studentId, row);
// 非法日期不写入,跳过该字段并计数
if (row.profileDate?.trim() && !this.isValidDateOnly(row.profileDate)) {
invalidDateSkipped++;
}
await this.upsertProfileFromImport(manager, studentId, row);
imported++;
}
if (this.hasResultData(row)) {
await this.upsertResultFromImport(studentId, row);
await this.upsertResultFromImport(manager, studentId, row);
imported++;
}
if (!phone) return imported;
if (!phone) return { imported, invalidDateSkipped };
const enrollmentRows = data.enrollments.get(phone) ?? [];
const examRows = data.examScores.get(phone) ?? [];
const learningRows = data.learningRecords.get(phone) ?? [];
// 按 studentId 批量预取既有档案行,内存匹配(保持 upsert 语义),
// 避免每个档案行单独 find 查询;新建保存后同步加入内存列表供后续行匹配。
let existingEnrollments: StudentEnrollment[] = [];
let existingExamScores: ExamScore[] = [];
let existingLearningRecords: LearningRecord[] = [];
if (enrollmentRows.length > 0) {
existingEnrollments = await manager.getRepository(StudentEnrollment).find({
where: { studentId },
});
}
if (examRows.length > 0) {
existingExamScores = await manager.getRepository(ExamScore).find({
where: { studentId },
});
}
if (learningRows.length > 0) {
existingLearningRecords = await manager.getRepository(LearningRecord).find({
where: { studentId },
});
}
const enrollmentByClassName = new Map<string, StudentEnrollment>();
for (const enrollmentRow of data.enrollments.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
for (const enrollmentRow of enrollmentRows) {
const enrollment = await this.upsertEnrollmentFromImport(
manager,
studentId,
enrollmentRow,
existingEnrollments,
);
if (!enrollment) continue;
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
imported++;
}
for (const examRow of data.examScores.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
for (const examRow of examRows) {
// 非法日期不写入,跳过该字段并计数
if (examRow.examDate?.trim() && !this.isValidDateOnly(examRow.examDate)) {
invalidDateSkipped++;
}
if (
await this.upsertExamScoreFromImport(
manager,
studentId,
examRow,
enrollmentByClassName,
existingExamScores,
)
) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
for (const learningRow of learningRows) {
// recordDate 必填:非法日期视为缺省,跳过整条并计数
if (learningRow.recordDate?.trim() && !this.isValidDateOnly(learningRow.recordDate)) {
invalidDateSkipped++;
continue;
}
if (await this.upsertLearningRecordFromImport(manager, studentId, learningRow, existingLearningRecords)) {
imported++;
}
}
return imported;
return { imported, invalidDateSkipped };
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
private async upsertProfileFromImport(
manager: EntityManager,
studentId: number,
row: StudentImportRow,
) {
const profileRepo = manager.getRepository(StudentProfile);
const entity =
(await this.profileRepo.findOne({ where: { studentId } })) ||
this.profileRepo.create({ studentId });
(await profileRepo.findOne({ where: { studentId } })) ||
profileRepo.create({ studentId });
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
if (row.grade?.trim()) entity.grade = row.grade.trim();
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
if (row.profileDate?.trim() && this.isValidDateOnly(row.profileDate)) {
entity.profileDate = row.profileDate.trim();
}
if (row.notes?.trim()) entity.notes = row.notes.trim();
await this.profileRepo.save(entity);
await profileRepo.save(entity);
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
private async upsertResultFromImport(
manager: EntityManager,
studentId: number,
row: StudentImportRow,
) {
const resultRepo = manager.getRepository(ResultArchive);
const entity =
(await this.resultRepo.findOne({ where: { studentId } })) ||
this.resultRepo.create({ studentId });
(await resultRepo.findOne({ where: { studentId } })) ||
resultRepo.create({ studentId });
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
if (row.professionalFinalScore !== undefined)
entity.professionalFinalScore = row.professionalFinalScore;
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
await this.resultRepo.save(entity);
await resultRepo.save(entity);
}
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
private async upsertEnrollmentFromImport(
manager: EntityManager,
studentId: number,
row: StudentEnrollmentImportRow,
existing: StudentEnrollment[],
) {
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
return null;
}
const existing = await this.enrollmentRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.courseCategory, row.courseCategory) &&
this.sameValue(item.classType, row.classType) &&
this.sameValue(item.className, row.className) &&
this.sameValue(item.startDate, row.startDate),
) || this.enrollmentRepo.create({ studentId });
const enrollmentRepo = manager.getRepository(StudentEnrollment);
let entity = existing.find(
(item) =>
this.sameValue(item.courseCategory, row.courseCategory) &&
this.sameValue(item.classType, row.classType) &&
this.sameValue(item.className, row.className) &&
this.sameValue(item.startDate, row.startDate),
);
if (!entity) {
entity = enrollmentRepo.create({ studentId });
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
existing.push(entity);
}
entity.courseCategory = row.courseCategory.trim();
entity.classType = row.classType.trim();
if (row.className?.trim()) entity.className = row.className.trim();
@@ -258,57 +535,74 @@ export class StudentsImportService {
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
if (row.status?.trim()) entity.status = row.status.trim();
else if (!entity.status) entity.status = 'active';
return this.enrollmentRepo.save(entity);
return enrollmentRepo.save(entity);
}
private async upsertExamScoreFromImport(
manager: EntityManager,
studentId: number,
row: ExamScoreImportRow,
enrollmentByClassName: Map<string, StudentEnrollment>,
existing: ExamScore[],
) {
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
const existing = await this.examScoreRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.examType, row.examType) &&
this.sameValue(item.examName, row.examName) &&
this.sameValue(item.subject, row.subject) &&
this.sameValue(item.examDate, row.examDate),
) || this.examScoreRepo.create({ studentId });
const examScoreRepo = manager.getRepository(ExamScore);
let entity = existing.find(
(item) =>
this.sameValue(item.examType, row.examType) &&
this.sameValue(item.examName, row.examName) &&
this.sameValue(item.subject, row.subject) &&
this.sameValue(item.examDate, row.examDate),
);
if (!entity) {
entity = examScoreRepo.create({ studentId });
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
existing.push(entity);
}
entity.examType = row.examType.trim();
entity.subject = row.subject.trim();
entity.score = row.score;
if (row.examName?.trim()) entity.examName = row.examName.trim();
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
if (row.rank !== undefined) entity.rank = row.rank;
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
if (row.examDate?.trim() && this.isValidDateOnly(row.examDate)) {
entity.examDate = row.examDate.trim();
}
if (row.enrollmentName?.trim()) {
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
if (enrollment) entity.enrollmentId = enrollment.id;
}
if (!entity.status) entity.status = 'active';
await this.examScoreRepo.save(entity);
await examScoreRepo.save(entity);
return true;
}
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
private async upsertLearningRecordFromImport(
manager: EntityManager,
studentId: number,
row: LearningRecordImportRow,
existing: LearningRecord[],
) {
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
const existing = await this.learningRecordRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.recordDate, row.recordDate) &&
this.sameValue(item.recordType, row.recordType) &&
this.sameValue(item.content, row.content),
) || this.learningRecordRepo.create({ studentId });
const learningRecordRepo = manager.getRepository(LearningRecord);
let entity = existing.find(
(item) =>
this.sameValue(item.recordDate, row.recordDate) &&
this.sameValue(item.recordType, row.recordType) &&
this.sameValue(item.content, row.content),
);
if (!entity) {
entity = learningRecordRepo.create({ studentId });
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
existing.push(entity);
}
entity.recordDate = row.recordDate.trim();
entity.recordType = row.recordType.trim();
entity.content = row.content.trim();
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
if (!entity.status) entity.status = 'active';
await this.learningRecordRepo.save(entity);
await learningRecordRepo.save(entity);
return true;
}
}

View File

@@ -77,6 +77,13 @@ export class ScheduleSyncService {
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
const classNameMap = await loadClassNames(this.classRepo, classIds);
// 同名班级会串组:统计本次参与同步的班级名出现次数,
// 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。
const classNameCounts = new Map<string, number>();
for (const name of classNameMap.values()) {
classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1);
}
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
@@ -203,8 +210,11 @@ export class ScheduleSyncService {
continue;
}
// 创建/匹配该班级的考勤组
const groupName = `排课_${className}`;
// 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组
const groupName =
(classNameCounts.get(className) ?? 0) > 1
? `排课_${classId}_${className}`
: `排课_${className}`;
let attendanceGroupId: number;
try {
const cached = groupByName.get(groupName);

View File

@@ -41,7 +41,15 @@ export class SyncRunner {
this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
throw error;
} finally {
await this.releaseLease(platform, runId);
// releaseLease 自身兜底:释放租约失败不能掩盖原始的同步结果/错误
try {
await this.releaseLease(platform, runId);
} catch (error) {
this.logger.error(
`${platform} sync lease release failed`,
error instanceof Error ? error.stack : undefined,
);
}
}
}

View File

@@ -189,6 +189,7 @@ export class SyncService {
let matched = 0;
let created = 0;
let skippedNoFields = 0;
await this.dataSource.transaction(async (manager) => {
const orgs = await manager.query<Array<{ id: number }>>(
'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1',
@@ -221,6 +222,11 @@ export class SyncService {
);
if (decision.action === 'match' && decision.matchStudentId) {
// 所有映射字段均为空时跳过更新,避免用空对象覆盖已有学生数据。
if (Object.keys(mappedValues).length === 0) {
skippedNoFields++;
continue;
}
await manager.update(Student, decision.matchStudentId, mappedValues);
matched++;
} else if (decision.action === 'create') {
@@ -242,7 +248,10 @@ export class SyncService {
return {
recordsCount: matched + created,
status: 'success',
message: `匹配 ${matched} 人,新增 ${created}`,
message:
skippedNoFields > 0
? `匹配 ${matched} 人,新增 ${created} 人,跳过无更新字段 ${skippedNoFields}`
: `匹配 ${matched} 人,新增 ${created}`,
};
});
}

View File

@@ -5,15 +5,29 @@ import { Bill } from '../entities/bill.entity';
const manager = (walletBalance: number) => {
const wallet = { id: 1, studentId: 10, balance: walletBalance };
const saved: any[] = [];
const updateQuery = {
update: jest.fn(),
set: jest.fn(),
setParameter: jest.fn(),
where: jest.fn(),
andWhere: jest.fn(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
updateQuery.update.mockReturnValue(updateQuery);
updateQuery.set.mockReturnValue(updateQuery);
updateQuery.setParameter.mockReturnValue(updateQuery);
updateQuery.where.mockReturnValue(updateQuery);
updateQuery.andWhere.mockReturnValue(updateQuery);
return {
wallet,
saved,
updateQuery,
value: {
findOne: jest.fn(async () => wallet),
findOneByOrFail: jest.fn(async () => wallet),
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
create: jest.fn((_entity: unknown, value: unknown) => value),
createQueryBuilder: jest.fn(),
createQueryBuilder: jest.fn(() => updateQuery),
},
};
};
@@ -91,6 +105,8 @@ describe('WalletsService financial boundaries', () => {
it('does not issue a second refund for an already cancelled bill', async () => {
const ctx = manager(10);
// 模拟条件 UPDATEstatus <> 'cancelled')未命中:已取消账单不产生任何退款
ctx.updateQuery.execute.mockResolvedValueOnce({ affected: 0 });
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
const bill = {
id: 12,
@@ -107,6 +123,25 @@ describe('WalletsService financial boundaries', () => {
expect(ctx.saved).toHaveLength(0);
});
it('throws 余额不足 when a concurrent debit drained the wallet', async () => {
const ctx = manager(40);
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
const bill = {
id: 13,
studentId: 10,
totalAmount: 100,
paidAmount: 0,
outstandingAmount: 100,
status: 'unpaid',
} as Bill;
ctx.updateQuery.execute.mockResolvedValue({ affected: 0 });
await expect(service.debitBill(ctx.value as any, bill, 1)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(ctx.saved.some((row) => row.type === 'bill_payment')).toBe(false);
});
it('rejects an amount that rounds to zero before opening a transaction', async () => {
const dataSource = { transaction: jest.fn() };
const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any);
@@ -152,3 +187,54 @@ describe('WalletsService wallet locking', () => {
expect(ctx.query.setLock).toHaveBeenCalledWith('pessimistic_write');
});
});
describe('WalletsService transaction history', () => {
it('limits transactions to the latest 200 rows', async () => {
const transactionRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new WalletsService({} as any, transactionRepo as any, {} as any, {} as any);
await service.findTransactions(10);
expect(transactionRepo.find).toHaveBeenCalledWith({
where: { studentId: 10 },
order: { createdAt: 'DESC' },
take: 200,
});
});
});
describe('WalletsService batch operation key length', () => {
it('caps operation_id at 64 chars while keeping the :studentId suffix', async () => {
const wallet = { id: 1, studentId: 10, balance: 2 };
const saved: any[] = [];
const txManager = {
findOne: jest.fn(async () => wallet),
findOneByOrFail: jest.fn(async () => wallet),
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
create: jest.fn((_entity: unknown, value: unknown) => value),
};
const dataSource = {
transaction: jest.fn(async (work: (manager: any) => Promise<any>) => work(txManager)),
};
const service = new WalletsService(
{} as any,
{} as any,
{ findOne: jest.fn(async () => ({ id: 10 })) } as any,
dataSource as any,
);
const longId = 'x'.repeat(70);
await service.batchChangeBalance({
operationId: longId,
studentIds: [10],
amount: -1,
type: 'adjustment',
description: '批量调账',
});
const tx = saved.find((row: any) => row.type === 'adjustment');
expect(tx.operationId).toBe(`${longId.slice(0, 50)}:10`);
expect(tx.operationId.length).toBeLessThanOrEqual(64);
expect(tx.operationId.endsWith(':10')).toBe(true);
});
});

View File

@@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { Room } from '../entities/room.entity';
import { escapeLike } from '../common/like-escape';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -30,7 +31,7 @@ export class WalletsService {
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${query.keyword}%`,
keyword: `%${escapeLike(query.keyword)}%`,
});
}
if (query?.roomType) {
@@ -99,7 +100,12 @@ export class WalletsService {
}
async findTransactions(studentId: number) {
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
// 只返回最近 200 条流水,避免无分页全量返回拖垮接口
return this.transactionRepo.find({
where: { studentId },
order: { createdAt: 'DESC' },
take: 200,
});
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
@@ -163,6 +169,11 @@ export class WalletsService {
const uniqueStudentIds = Array.from(new Set(batch.studentIds));
const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
for (const studentId of uniqueStudentIds) {
// operation_id 为 varchar(64)operationId 最长 64直接拼 :studentId 会溢出。
// 截断 operationId 前缀并整体兜底截到 64保证不超长且尽量保留 :studentId。
const opKey = operationId
? `${operationId.slice(0, 50)}:${studentId}`.slice(0, 64)
: undefined;
results.push(
await this.changeBalanceOnce(
{
@@ -172,7 +183,7 @@ export class WalletsService {
description: batch.description,
},
recordedBy,
operationId ? `${operationId}:${studentId}` : undefined,
opKey,
manager,
),
);
@@ -206,11 +217,24 @@ export class WalletsService {
return manager.save(bill);
}
// 原子扣款:由数据库在同一 UPDATE 中扣减并校验余额,避免并发下 check-then-act 导致 double-spend
const debit = await manager
.createQueryBuilder()
.update(StudentWallet)
.set({ balance: () => 'balance - :amount' })
.setParameter('amount', amount)
.where('id = :id', { id: wallet.id })
.andWhere('balance >= :amount', { amount })
.execute();
if (debit.affected !== 1) {
throw new BadRequestException('余额不足');
}
// balanceAfter 为事务内估算值(内存旧值 ± amount原子 UPDATE 已由数据库完成,
// 并发下流水余额可能与最终 DB 余额略有偏差,但金额本身由数据库条件更新保证一致。
wallet.balance = money(Number(wallet.balance) - amount);
bill.paidAmount = money(paid + amount);
bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
await manager.save(wallet);
await manager.save(bill);
await manager.save(
manager.create(WalletTransaction, {
@@ -227,13 +251,38 @@ export class WalletsService {
}
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
if (bill.status === 'cancelled') return bill;
// 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。
// 条件 UPDATE 与调用方事务内的悲观锁是双保险,并发重复退款不会重复冲正。
const claimed = await manager
.createQueryBuilder()
.update(Bill)
.set({ status: 'cancelled', cancelledAt: () => 'NOW()', cancelReason: reason })
.where('id = :id', { id: bill.id })
.andWhere('status <> :cancelled', { cancelled: 'cancelled' })
.execute();
if (claimed.affected !== 1) return bill; // 已被其他请求取消,直接返回(幂等)
bill.status = 'cancelled';
bill.cancelledAt = new Date();
bill.cancelReason = reason;
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
if (paid > 0) {
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
// 原子退款:余额累加由数据库完成,避免并发覆盖
const credit = await manager
.createQueryBuilder()
.update(StudentWallet)
.set({ balance: () => 'balance + :amount' })
.setParameter('amount', paid)
.where('id = :id', { id: wallet.id })
.execute();
if (credit.affected !== 1) {
throw new BadRequestException('学生钱包不存在,退款失败');
}
// balanceAfter 为事务内估算值(内存旧值 + paid原子 UPDATE 已由数据库完成,
// 并发下流水余额可能与最终 DB 余额略有偏差,但冲正金额本身由数据库累加保证一致。
wallet.balance = money(Number(wallet.balance) + paid);
await manager.save(wallet);
await manager.save(
manager.create(WalletTransaction, {
studentId: bill.studentId,
@@ -246,11 +295,8 @@ export class WalletsService {
}),
);
}
bill.status = 'cancelled';
bill.paidAmount = 0;
bill.outstandingAmount = 0;
bill.cancelledAt = new Date();
bill.cancelReason = reason;
return manager.save(bill);
}
@@ -269,9 +315,14 @@ export class WalletsService {
.getMany();
const settled: Bill[] = [];
for (const bill of bills) {
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
if (!wallet || money(wallet.balance) <= 0) break;
settled.push(await this.debitBill(manager, bill, recordedBy));
const paidBefore = money(bill.paidAmount);
const result = await this.debitBill(manager, bill, recordedBy);
if (money(result.paidAmount) > paidBefore) {
settled.push(result);
} else {
// 余额已不足以支付后续账单,停止结算
break;
}
}
return settled;
}