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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user