wenminjie
2026-07-17 cd44290dc230d64ef9e382777b9722e81a661570
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
package com.ruoyi.web.controller.service;
 
import cn.hutool.core.date.DateUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.system.domain.SysVisitorRecord;
import com.ruoyi.system.domain.SysVisitorSignoutTemp;
import com.ruoyi.system.dto.ApiVisitorRecodeSingoutRequest;
import com.ruoyi.system.mapper.SysVisitorRecordMapper;
import com.ruoyi.system.mapper.SysVisitorSignoutTempMapper;
import com.ruoyi.web.controller.tool.FaceBase64ToUrlUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
 
import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.TimeUnit;
 
/**
 * 访客签离离线数据同步服务
 *
 * @author ruoyi
 */
@Slf4j
@Service
public class ApiPullSignOutDataService {
 
    @Resource
    private SysVisitorRecordMapper visitorRecordMapper;
 
    @Resource
    private SysVisitorSignoutTempMapper signoutTempMapper;
 
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
 
    /**
     * 处理签离数据(支持签离先到的场景)
     */
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult processSignOutData(List<ApiVisitorRecodeSingoutRequest> requestList) {
        if (requestList == null || requestList.isEmpty()) {
            throw new ServiceException("上传失败,未获取到签离数据");
        }
 
        int successCount = 0;
        int skipCount = 0;
        int errorCount = 0;
 
        for (ApiVisitorRecodeSingoutRequest request : requestList) {
            try {
                if (request == null) {
                    log.warn("签离数据为空,已跳过");
                    errorCount++;
                    continue;
                }
 
                // 1. 参数校验
                if (!StringUtils.hasText(request.getIdCard())) {
                    log.warn("身份证号为空,已跳过,人员姓名: {}", request.getIntervieweeName());
                    errorCount++;
                    continue;
                }
 
                if (com.ruoyi.common.utils.StringUtils.isNotEmpty(request.getSignImageUrl())) {
                    String url = FaceBase64ToUrlUtil.processFaceImage(request.getSignImageUrl(), request.getIdCard());
                    request.setSignImageUrl(url);
                }
 
                // 2. 防重校验(身份证号+签离时间)
                String uniqueKey = generateUniqueKey(request);
                String redisKey = "offline:signout:" + uniqueKey;
                if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
                    log.warn("签离数据重复,已跳过, 身份证号: {}, 签离时间: {}",
                            request.getIdCard(), request.getSignTime());
                    skipCount++;
                    continue;
                }
 
                // 3. 处理单条签离数据
                boolean processed = processSingleSignOutData(request);
                if (processed) {
                    // 记录处理成功状态到Redis(7天过期)
                    redisTemplate.opsForValue().set(redisKey, "1", 7, TimeUnit.DAYS);
                    successCount++;
                } else {
                    errorCount++;
                }
 
            } catch (Exception e) {
                log.error("处理签离数据失败, 身份证号: {}",
                        request.getIdCard(), e);
                errorCount++;
            }
        }
 
        log.info("离线签离数据同步完成,成功:{},跳过:{},失败:{}", successCount, skipCount, errorCount);
 
        if (errorCount > 0) {
            return AjaxResult.error(String.format("同步完成,成功:%d,跳过:%d,失败:%d",
                    successCount, skipCount, errorCount));
        }
        return AjaxResult.success(String.format("同步成功,成功:%d,跳过:%d", successCount, skipCount));
    }
 
    /**
     * 处理单条签离数据
     *
     * @param request 签离请求
     * @return 是否处理成功
     */
    private boolean processSingleSignOutData(ApiVisitorRecodeSingoutRequest request) {
        // 设置签离时间(如果没有则用当前时间)
        Date signOutTime = request.getSignTime() != null ?
                request.getSignTime() : new Date();
 
        // 1. 先保存签离数据到临时表
        SysVisitorSignoutTemp signoutTemp = new SysVisitorSignoutTemp();
        BeanUtils.copyProperties(request, signoutTemp);
        signoutTemp.setSignTime(signOutTime);
        signoutTemp.setRelateStatus("0");
        signoutTemp.setCreateTime(new Date());
        signoutTempMapper.insert(signoutTemp);
        log.info("签离数据已保存到临时表,tempId: {}, 身份证号: {}",
                signoutTemp.getTempId(), request.getIdCard());
 
        // 2. 尝试匹配签到记录(查找未签离的记录)
        SysVisitorRecord matchedRecord = findMatchSignInRecord(request.getIdCard(), signOutTime);
 
        if (matchedRecord != null) {
            // 找到匹配的签到记录,更新签离信息
            return associateWithSignInRecord(matchedRecord, signoutTemp);
        } else {
            // 未找到匹配的签到记录,暂存临时表等待后续关联
            log.info("未找到匹配的签到记录,签离数据暂存临时表,tempId: {}, 身份证号: {}",
                    signoutTemp.getTempId(), request.getIdCard());
            return true;
        }
    }
 
    /**
     * 查找匹配的签到记录
     *
     * @param idCard 身份证号
     * @param signOutTime 签离时间
     * @return 匹配的签到记录
     */
    private SysVisitorRecord findMatchSignInRecord(String idCard, Date signOutTime) {
        // 获取签离时间当天的开始和结束时间
        Date signOutDayStart = DateUtil.beginOfDay(signOutTime);
        Date signOutDayEnd = DateUtil.endOfDay(signOutTime);
 
        // 策略1: 查找当天进行的记录(状态为VISITING或PENDING_CONFIRM)
        // todo 业务上有个字段决定来访日期是 当天 还是 多天
        LambdaQueryWrapper<SysVisitorRecord> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysVisitorRecord::getIdCard, idCard)
                .in(SysVisitorRecord::getStatus, Arrays.asList("VISITING", "PENDING_CONFIRM"))
                .eq(SysVisitorRecord::getDelFlag, "0")
                .orderByDesc(SysVisitorRecord::getRegisterTime)
                .last("limit 1");
 
        SysVisitorRecord record = visitorRecordMapper.selectOne(wrapper);
 
        if (record != null) {
            // 检查签到日期是否在签离日期前后3天内
            Date registerDayStart = DateUtil.beginOfDay(record.getRegisterTime());
            Date threeDaysBefore = DateUtil.offsetDay(signOutTime, -3);
            Date threeDaysAfter = DateUtil.offsetDay(signOutTime, 3);
 
            if (record.getRegisterTime().after(threeDaysBefore) &&
                    record.getRegisterTime().before(threeDaysAfter)) {
                log.info("找到匹配的签到记录,recordId: {}, registerTime: {}, signOutTime: {}",
                        record.getRecordId(), record.getRegisterTime(), signOutTime);
                return record;
            }
        }
 
        // 策略2: 查找3天内已签离但可能状态异常的记录(防止数据不一致)
        Date threeDaysAgo = DateUtil.offsetDay(signOutTime, -3);
        wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysVisitorRecord::getIdCard, idCard)
                .in(SysVisitorRecord::getStatus, Arrays.asList("SIGNED_OUT", "OVERDUE_AUTO_SIGNED_OUT"))
                .eq(SysVisitorRecord::getDelFlag, "0")
                .ge(SysVisitorRecord::getSignTime, threeDaysAgo)
                .le(SysVisitorRecord::getSignTime, signOutDayEnd)
                .orderByDesc(SysVisitorRecord::getSignTime)
                .last("limit 1");
 
        record = visitorRecordMapper.selectOne(wrapper);
 
        if (record != null) {
            log.warn("找到已签离的记录,可能需要重新关联,recordId: {}, status: {}",
                    record.getRecordId(), record.getStatus());
            // 检查签离时间是否与当前签离时间接近(30分钟内)
            if (isTimeClose(record.getSignTime(), signOutTime, 30)) {
                log.info("签离时间接近,可能为重复数据,recordId: {}", record.getRecordId());
                return record;
            }
        }
 
        log.info("未找到匹配的签到记录,身份证号: {}, 签离时间: {}", idCard, signOutTime);
        return null;
    }
 
    /**
     * 关联签离记录到签到记录
     *
     * @param record 签到记录
     * @param signoutTemp 签离临时记录
     * @return 是否关联成功
     */
    private boolean associateWithSignInRecord(SysVisitorRecord record,
                                              SysVisitorSignoutTemp signoutTemp) {
        // 1. 检查记录是否已有签离时间(防止重复关联)
        if (record.getSignTime() != null) {
            log.warn("签到记录已有签离时间,跳过关联,recordId: {}, 已有签离时间: {}",
                    record.getRecordId(), record.getSignTime());
 
            // 如果签离时间不同,记录日志
            if (!isTimeClose(record.getSignTime(), signoutTemp.getSignTime(), 5)) {
                log.warn("签离时间不一致,已有: {}, 新: {}",
                        record.getSignTime(), signoutTemp.getSignTime());
            }
 
            // 更新临时表状态但不更新主表
            signoutTemp.setRelateStatus("1");
            signoutTemp.setRelateRecordId(record.getRecordId());
            signoutTemp.setUpdateTime(new Date());
            signoutTempMapper.updateById(signoutTemp);
            return true;
        }
 
        // 2. 计算签离状态
        String status = calculateSignOutStatus(record, signoutTemp.getSignTime());
 
        // 3. 更新签到记录
        LambdaUpdateWrapper<SysVisitorRecord> updateWrapper = new LambdaUpdateWrapper<>();
        updateWrapper.eq(SysVisitorRecord::getRecordId, record.getRecordId())
                .set(SysVisitorRecord::getSignTime, signoutTemp.getSignTime())
                .set(SysVisitorRecord::getSignReceiverName, signoutTemp.getSignReceiverName())
                .set(SysVisitorRecord::getSignVerifyMethod, signoutTemp.getSignVerifyMethod())
                .set(SysVisitorRecord::getSignImageUrl, signoutTemp.getSignImageUrl())
                .set(SysVisitorRecord::getStatus, status)
                .set(SysVisitorRecord::getUpdateTime, new Date());
 
        // 添加备注信息
        String remark = buildSignOutRemark(record, signoutTemp, status);
        if (StringUtils.hasText(remark)) {
            updateWrapper.set(SysVisitorRecord::getRemark,
                    (record.getRemark() != null ? record.getRemark() + ";" : "") + remark);
        }
 
        int updateCount = visitorRecordMapper.update(null, updateWrapper);
 
        if (updateCount <= 0) {
            log.error("关联签离记录失败,更新影响行数为0,recordId: {}", record.getRecordId());
            return false;
        }
 
        // 4. 更新签离临时表关联状态
        signoutTemp.setRelateStatus("1");
        signoutTemp.setRelateRecordId(record.getRecordId());
        signoutTemp.setUpdateTime(new Date());
        signoutTempMapper.updateById(signoutTemp);
 
        log.info("签离数据关联成功,recordId: {}, tempId: {}, 状态: {}, 身份证号: {}",
                record.getRecordId(), signoutTemp.getTempId(), status, record.getIdCard());
 
        return true;
    }
 
    /**
     * 计算签离状态
     *
     * @param record 签到记录
     * @param signOutTime 签离时间
     * @return 签离状态
     */
    private String calculateSignOutStatus(SysVisitorRecord record, Date signOutTime) {
        Date validEndTime = record.getValidEndTime();
 
        // 如果没有有效期结束时间,默认正常签离
        if (validEndTime == null) {
            log.warn("记录没有设置有效期结束时间,默认正常签离,recordId: {}", record.getRecordId());
            return "SIGNED_OUT";
        }
 
        // 获取有效期的最后一天的结束时间(23:59:59)
        Date validEndDayEnd = DateUtil.endOfDay(validEndTime);
        // 获取有效期结束日期的第二天开始时间(00:00:00)
        Date nextDayStart = DateUtil.beginOfDay(DateUtil.offsetDay(validEndTime, 1));
 
        // 情况1: 签离时间在有效期内(<= 有效期结束日期的23:59:59)
        if (signOutTime.before(validEndDayEnd) || signOutTime.equals(validEndDayEnd)) {
            return "SIGNED_OUT";
        }
 
        // 情况2: 签离时间在有效期结束后的第二天及以后(>= 第二天00:00:00)
        if (signOutTime.after(nextDayStart) || signOutTime.equals(nextDayStart)) {
            return "OVERDUE_AUTO_SIGNED_OUT";
        }
 
        // 情况3: 签离时间在有效期结束后但还在当天
        if (signOutTime.after(validEndTime) && signOutTime.before(nextDayStart)) {
            return "PENDING_CONFIRM";
        }
 
        // 默认情况
        return "SIGNED_OUT";
    }
 
    /**
     * 构建签离备注信息
     */
    private String buildSignOutRemark(SysVisitorRecord record,
                                      SysVisitorSignoutTemp signoutTemp,
                                      String status) {
        StringBuilder remark = new StringBuilder();
        remark.append("签离设备: ").append(signoutTemp.getDeviceNo());
        remark.append("; 签离时间: ").append(DateUtil.format(signoutTemp.getSignTime(), "yyyy-MM-dd HH:mm:ss"));
 
        switch (status) {
            case "SIGNED_OUT":
                remark.append("; 正常签离");
                break;
            case "PENDING_CONFIRM":
                remark.append("; 逾期滞留(超过有效期,有效期至: ")
                        .append(DateUtil.format(record.getValidEndTime(), "yyyy-MM-dd HH:mm:ss"))
                        .append(")");
                break;
            case "OVERDUE_AUTO_SIGNED_OUT":
                remark.append("; 超期自动签离(有效期至: ")
                        .append(DateUtil.format(record.getValidEndTime(), "yyyy-MM-dd HH:mm:ss"))
                        .append(")");
                break;
            default:
                break;
        }
        return remark.toString();
    }
 
    /**
     * 判断两个时间是否接近(分钟)
     */
    private boolean isTimeClose(Date time1, Date time2, int minutes) {
        if (time1 == null || time2 == null) {
            return false;
        }
        long diff = Math.abs(time1.getTime() - time2.getTime());
        long diffMinutes = diff / (60 * 1000);
        return diffMinutes <= minutes;
    }
 
    /**
     * 生成唯一键(用于防重)
     */
    private String generateUniqueKey(ApiVisitorRecodeSingoutRequest request) {
        StringBuilder sb = new StringBuilder();
//        sb.append(request.getDeviceNo()).append(":");
        sb.append(request.getIdCard()).append(":");
        sb.append(request.getSignTime() != null ?
                request.getSignTime().getTime() : System.currentTimeMillis());
        return sb.toString();
    }
}