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