由 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)
566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
||
import { AttendanceImportService } from './attendance-import.service';
|
||
import { DingTalkService } from '../integration/dingtalk.service';
|
||
import { AttendanceService } from './attendance.service';
|
||
|
||
describe('AttendanceImportService', () => {
|
||
const dingRawRepo = {
|
||
find: jest.fn(),
|
||
findOne: jest.fn(),
|
||
save: jest.fn(),
|
||
};
|
||
const studentRepo = { findOne: jest.fn(), find: jest.fn() };
|
||
const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
|
||
const dingTalkService = {
|
||
fetchAttendanceResults: jest.fn(),
|
||
};
|
||
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,
|
||
);
|
||
});
|
||
|
||
it('splits DingTalk requests by at most 50 users and 7 calendar days without offset pagination', async () => {
|
||
const userIds = Array.from({ length: 51 }, (_, index) => `user-${index + 1}`);
|
||
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
|
||
|
||
await (service as any).fetchAllPages({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-10',
|
||
userIds,
|
||
});
|
||
|
||
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(4);
|
||
expect(dingTalkService.fetchAttendanceResults.mock.calls.map(([params]) => params)).toEqual([
|
||
{
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-07',
|
||
userIds: userIds.slice(0, 50),
|
||
},
|
||
{
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-07',
|
||
userIds: userIds.slice(50),
|
||
},
|
||
{
|
||
startDate: '2026-07-08',
|
||
endDate: '2026-07-10',
|
||
userIds: userIds.slice(0, 50),
|
||
},
|
||
{
|
||
startDate: '2026-07-08',
|
||
endDate: '2026-07-10',
|
||
userIds: userIds.slice(50),
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('rejects an attendance import without DingTalk user IDs', async () => {
|
||
await expect(
|
||
(service as any).fetchAllPages({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-01',
|
||
userIds: [],
|
||
}),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
|
||
expect(dingTalkService.fetchAttendanceResults).not.toHaveBeenCalled();
|
||
});
|
||
it('stores the DingTalk user name returned with the attendance record', 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-1',
|
||
checkType: 'OnDuty',
|
||
});
|
||
|
||
expect(entity.userName).toBe('张三');
|
||
});
|
||
|
||
it('stores DingTalk punch source and attendance machine metadata', 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-1',
|
||
checkType: 'OnDuty',
|
||
sourceType: 'ATM',
|
||
deviceName: '东门考勤机',
|
||
deviceId: 'ATM-01',
|
||
});
|
||
|
||
expect(entity).toEqual(
|
||
expect.objectContaining({
|
||
punchSource: 'ATM',
|
||
punchDeviceName: '东门考勤机',
|
||
punchDeviceId: 'ATM-01',
|
||
}),
|
||
);
|
||
});
|
||
|
||
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([
|
||
{
|
||
userId: 'ding-1',
|
||
userName: '',
|
||
workDate: '2026-07-01',
|
||
timeResult: 'Normal',
|
||
locationResult: '',
|
||
planCheckTime: '',
|
||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||
checkId: 'check-1',
|
||
checkType: 'OnDuty',
|
||
},
|
||
]);
|
||
dingRawRepo.find.mockResolvedValue([]);
|
||
studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]);
|
||
studentRepo.find.mockResolvedValue([{ id: 3, name: '张三' }]);
|
||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||
|
||
await service.importFromDingTalk({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-01',
|
||
userIds: ['ding-1'],
|
||
autoMatch: false,
|
||
});
|
||
|
||
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||
[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();
|
||
});
|
||
|
||
|
||
it('auto-matches previously imported duplicate records', 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-1',
|
||
checkType: 'OnDuty',
|
||
sourceType: 'ATM',
|
||
deviceName: '东门考勤机',
|
||
deviceId: 'ATM-01',
|
||
},
|
||
]);
|
||
dingRawRepo.find.mockResolvedValue([{
|
||
dingId: 'check-1',
|
||
punchSource: null,
|
||
punchDeviceName: null,
|
||
punchDeviceId: null,
|
||
rawData: '',
|
||
}]);
|
||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||
|
||
const result = await service.importFromDingTalk({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-01',
|
||
userIds: ['ding-1'],
|
||
autoMatch: true,
|
||
});
|
||
|
||
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||
[expect.objectContaining({
|
||
dingId: 'check-1',
|
||
punchSource: 'ATM',
|
||
punchDeviceName: '东门考勤机',
|
||
punchDeviceId: 'ATM-01',
|
||
})],
|
||
{ chunk: 50 },
|
||
);
|
||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||
expect(result.matched).toBe(1);
|
||
});
|
||
|
||
it('preserves existing device metadata when a duplicate response omits it', async () => {
|
||
const existing = {
|
||
dingId: 'check-keep-device',
|
||
punchSource: 'ATM',
|
||
punchDeviceName: '东门考勤机',
|
||
punchDeviceId: 'ATM-01',
|
||
rawData: '{}',
|
||
};
|
||
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
|
||
userId: 'ding-1',
|
||
userName: '张三',
|
||
workDate: '2026-07-01',
|
||
timeResult: 'Normal',
|
||
locationResult: '',
|
||
planCheckTime: '',
|
||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||
checkId: 'check-keep-device',
|
||
checkType: 'OnDuty',
|
||
sourceType: '',
|
||
}]);
|
||
dingRawRepo.find.mockResolvedValue([existing]);
|
||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
|
||
|
||
await service.importFromDingTalk({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-01',
|
||
userIds: ['ding-1'],
|
||
autoMatch: true,
|
||
});
|
||
|
||
expect(existing).toEqual(expect.objectContaining({
|
||
punchSource: 'ATM',
|
||
punchDeviceName: '东门考勤机',
|
||
punchDeviceId: 'ATM-01',
|
||
}));
|
||
expect(dingRawRepo.save).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('scopes SSE progress events to the importing user', async () => {
|
||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||
{
|
||
userId: 'ding-1',
|
||
userName: '李四',
|
||
workDate: '2026-07-01',
|
||
timeResult: 'Normal',
|
||
locationResult: '',
|
||
planCheckTime: '',
|
||
actualCheckTime: '2026-07-01T09:00:00.000Z',
|
||
checkId: 'check-2',
|
||
checkType: 'OnDuty',
|
||
},
|
||
]);
|
||
|
||
const events: Array<{ phase: string; userId?: number }> = [];
|
||
const sub = service.progress$.subscribe((event) => {
|
||
events.push({ phase: event.phase, userId: event.userId });
|
||
});
|
||
|
||
await service.importFromDingTalk({
|
||
startDate: '2026-07-01',
|
||
endDate: '2026-07-01',
|
||
userIds: ['ding-1'],
|
||
userId: 42,
|
||
});
|
||
|
||
sub.unsubscribe();
|
||
|
||
expect(events.length).toBeGreaterThan(0);
|
||
for (const event of events) {
|
||
expect(event.userId).toBe(42);
|
||
}
|
||
});
|
||
|
||
it('emits userId undefined when import has no HTTP user', async () => {
|
||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||
{
|
||
userId: 'ding-2',
|
||
userName: '王五',
|
||
workDate: '2026-07-02',
|
||
timeResult: 'Normal',
|
||
locationResult: '',
|
||
planCheckTime: '',
|
||
actualCheckTime: '2026-07-02T10:00:00.000Z',
|
||
checkId: 'check-3',
|
||
checkType: 'OnDuty',
|
||
},
|
||
]);
|
||
|
||
const events: Array<{ phase: string; userId?: number }> = [];
|
||
const sub = service.progress$.subscribe((event) => {
|
||
events.push({ phase: event.phase, userId: event.userId });
|
||
});
|
||
|
||
await service.importFromDingTalk({
|
||
startDate: '2026-07-02',
|
||
endDate: '2026-07-02',
|
||
userIds: ['ding-2'],
|
||
});
|
||
|
||
sub.unsubscribe();
|
||
|
||
expect(events.length).toBeGreaterThan(0);
|
||
for (const event of events) {
|
||
expect(event.userId).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|