
这篇不讲"企业系统应该数字化"这种空话,直接从智慧考勤项目里抽一条可以复用的工程链路:移动端采集打卡数据,后端按规则落库,PC 后台查询和修正,最后沉淀成日统计。
真实项目里这一条链路分布在几个位置:
- 后端实体:`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/entity/KqAttendanceRecord.java`
- 移动端接口:`zhkq-uniapp/src/common/http/api.js`
- 移动端打卡页:`zhkq-uniapp/src/pages/wqdk/wqgw.vue`
- App 控制器:`AppKqAttendanceRecordController`
- 后端服务:`IKqAttendanceRecordService`、`KqAttendanceRecordServiceImpl`
- Mapper:`KqAttendanceRecordMapper`、`KqAttendanceRecordMapper.xml`
- PC 后台接口:`zhkq-web/src/views/attendanceRecord/clockIn/clock.in.api.ts`

这套设计能复用到巡检、运维、门店拜访、工单签到、设备保养、上门服务等系统。原因很简单:这些业务都不是只要一条"提交记录",而是要解决"现场数据可信、异常可解释、后台可追溯、统计可回算"。
下面给一个脱敏后的完整源码 Demo。代码不是内部项目原文件全文复制,而是参照现有工程的字段、分层、接口风格和业务边界整理出来的最小闭环。
一、为什么考勤记录不能只存一张流水表
智慧考勤里的 `KqAttendanceRecord` 不是简单的"某人几点打了卡"。真实字段至少要覆盖五类信息:
|------|------------------------------------------------------------------------|----------------------------|
| 字段类型 | 核心字段 | 作用 |
| 人员组织 | `personId`、`personName`、`unitId`、`departmentId` | 支持租户、单位、部门维度查询 |
| 打卡类型 | `clockType`、`upDownWorkClock` | 区分单位打卡、外勤打卡、紧急外勤、上班、下班 |
| 证据数据 | `clockTime`、`longitude`、`latitude`、`clockAddress`、`clockImg` | 形成位置、时间、图片证据链 |
| 规则关联 | `attendanceRule`、`attendanceWork` | 保留当时命中的规则和班次,后续规则变更不影响历史解释 |
| 流程状态 | `clockStatus`、`afStatus`、`leaveRecordId`、`createType` | 支持异常、申诉、请假、定时任务补记录 |
如果只存 `person_id + clock_time`,后面所有问题都会变成查聊天记录:员工说定位不准,主管说超出范围,HR 说月底统计对不上,老板说报表不可信。
二、完整源码 Demo:考勤打卡闭环
1. Entity:考勤记录表
package com.demo.attendance.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 考勤记录实体。
* 负责保存员工一次打卡的结果和证据,业务边界是"事件记录",不直接承担规则配置和月度统计职责。
*/
@Data
@TableName("demo_attendance_record")
public class AttendanceRecord {
/** 主键 */
private String id;
/** 员工ID */
private String personId;
/** 员工姓名,脱敏 Demo 中只保存展示名 */
private String personName;
/** 单位ID */
private String unitId;
/** 部门ID */
private String departmentId;
/** 打卡类型:1单位打卡,2外勤打卡,3紧急外勤 */
private String clockType;
/** 上下班类型:1上班,2下班 */
private String upDownWorkClock;
/** 打卡状态:0正常,1迟到,2缺卡,3早退,4旷工,5请假,6补卡 */
private Integer clockStatus;
/** 申诉状态:0正常,1申诉中,3驳回 */
private Integer appealStatus;
/** 打卡时间 */
private LocalDateTime clockTime;
/** 打卡地址 */
private String clockAddress;
/** 经度 */
private String longitude;
/** 纬度 */
private String latitude;
/** 打卡照片,多个文件用逗号分隔 */
private String clockImg;
/** 外勤或异常说明 */
private String attendanceContent;
/** 命中的考勤规则ID */
private String attendanceRule;
/** 命中的班次ID */
private String attendanceWork;
/** 创建方式:1员工打卡,2定时任务补记录 */
private Integer createType;
/** 软删除标识:0正常,1删除 */
private Integer delFlag;
}
2. DTO:移动端打卡入参
package com.demo.attendance.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 移动端打卡入参。
* 只接收客户端可以提供的现场证据,人员、组织和最终状态由后端根据登录态和规则补齐。
*/
@Data
public class ClockInDTO {
/** 上下班类型:1上班,2下班 */
@NotBlank(message = "上下班类型不能为空")
private String upDownWorkClock;
/** 打卡类型:1单位打卡,2外勤打卡,3紧急外勤 */
@NotBlank(message = "打卡类型不能为空")
private String clockType;
/** 客户端采集的打卡时间 */
@NotNull(message = "打卡时间不能为空")
private LocalDateTime clockTime;
/** 经度 */
private String longitude;
/** 纬度 */
private String latitude;
/** 客户端解析到的地址,缺失时后端兜底解析 */
private String clockAddress;
/** 打卡照片 */
private String clockImg;
/** 外勤说明 */
private String attendanceContent;
/** 移动端命中的规则ID */
private String ruleId;
/** 移动端命中的班次ID */
private String workId;
}
3. VO:PC 后台列表返回
package com.demo.attendance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 考勤记录展示对象。
* 用于 PC 后台列表和移动端详情,避免把内部控制字段直接暴露给页面。
*/
@Data
public class AttendanceRecordVO {
private String id;
private String personName;
private String departmentName;
private String clockTypeName;
private String upDownWorkClockName;
private String clockStatusName;
private Integer appealStatus;
private LocalDateTime clockTime;
private String clockAddress;
private String clockImg;
private String attendanceContent;
}
4. Mapper:MyBatis Plus 基础查询 + 日记录查询
package com.demo.attendance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.demo.attendance.entity.AttendanceRecord;
import com.demo.attendance.vo.AttendanceRecordVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
/**
* 考勤记录 Mapper。
* 负责记录表的基础 CRUD 和面向页面的组合查询,不处理规则判断。
*/
@Mapper
public interface AttendanceRecordMapper extends BaseMapper<AttendanceRecord> {
/**
* 查询某员工某天的打卡记录。
*
* @param personId 员工ID
* @param date 日期
* @return 当日记录
*/
List<AttendanceRecordVO> selectDayRecord(@Param("personId") String personId,
@Param("date") LocalDate date);
}
对应 XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.demo.attendance.mapper.AttendanceRecordMapper">
<select id="selectDayRecord" resultType="com.demo.attendance.vo.AttendanceRecordVO">
SELECT
id,
person_name AS personName,
department_id AS departmentName,
CASE clock_type
WHEN '1' THEN '单位打卡'
WHEN '2' THEN '外勤打卡'
WHEN '3' THEN '紧急外勤'
ELSE '未知'
END AS clockTypeName,
CASE up_down_work_clock
WHEN '1' THEN '上班打卡'
WHEN '2' THEN '下班打卡'
ELSE '未知'
END AS upDownWorkClockName,
CASE clock_status
WHEN 0 THEN '正常'
WHEN 1 THEN '迟到'
WHEN 2 THEN '缺卡'
WHEN 3 THEN '早退'
WHEN 4 THEN '旷工'
WHEN 5 THEN '请假'
WHEN 6 THEN '补卡'
ELSE '异常'
END AS clockStatusName,
appeal_status AS appealStatus,
clock_time AS clockTime,
clock_address AS clockAddress,
clock_img AS clockImg,
attendance_content AS attendanceContent
FROM demo_attendance_record
WHERE person_id = #{personId}
AND del_flag = 0
AND DATE(clock_time) = #{date}
ORDER BY clock_time ASC
</select>
</mapper>
5. Service:接口定义
package com.demo.attendance.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.demo.attendance.dto.ClockInDTO;
import com.demo.attendance.entity.AttendanceRecord;
import com.demo.attendance.vo.AttendanceRecordVO;
import java.time.LocalDate;
import java.util.List;
/**
* 考勤记录业务服务。
* 负责打卡落库、重复打卡控制、状态判断和日统计触发。
*/
public interface AttendanceRecordService extends IService<AttendanceRecord> {
/**
* 员工打卡。
*
* @param dto 移动端打卡数据
* @param loginUserId 当前登录员工ID
* @return 结果提示
*/
String clock(ClockInDTO dto, String loginUserId);
/**
* 查询某天考勤记录。
*
* @param personId 员工ID
* @param date 日期
* @return 当日打卡记录
*/
List<AttendanceRecordVO> dayRecord(String personId, LocalDate date);
}
6. ServiceImpl:状态判断、时间防篡改和日统计更新
package com.demo.attendance.service.impl;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.demo.attendance.dto.ClockInDTO;
import com.demo.attendance.entity.AttendanceRecord;
import com.demo.attendance.mapper.AttendanceRecordMapper;
import com.demo.attendance.service.AttendanceRecordService;
import com.demo.attendance.service.AttendanceStatsService;
import com.demo.attendance.vo.AttendanceRecordVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 考勤记录业务实现。
* 从真实项目抽取了三个关键约束:客户端时间不可信、同一班次不能重复覆盖、记录落库后要同步日统计。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttendanceRecordServiceImpl
extends ServiceImpl<AttendanceRecordMapper, AttendanceRecord>
implements AttendanceRecordService {
private final AttendanceStatsService attendanceStatsService;
@Override
@Transactional(rollbackFor = Exception.class)
public String clock(ClockInDTO dto, String loginUserId) {
LocalDateTime now = LocalDateTime.now();
// 防止手机系统时间被调到未来。真实项目中超过当前时间 10 分钟会拒绝。
if (dto.getClockTime().isAfter(now.plusMinutes(10))) {
throw new IllegalArgumentException("打卡失败,请先校准手机系统时间");
}
// 防止补很久以前的卡。真实项目中超过 90 天会重置为当前时间。
if (dto.getClockTime().isBefore(now.minusDays(90))) {
dto.setClockTime(now);
}
LocalDate clockDate = dto.getClockTime().toLocalDate();
boolean duplicated = this.count(new LambdaQueryWrapper<AttendanceRecord>()
.eq(AttendanceRecord::getPersonId, loginUserId)
.eq(AttendanceRecord::getAttendanceWork, dto.getWorkId())
.eq(AttendanceRecord::getUpDownWorkClock, dto.getUpDownWorkClock())
.apply("DATE(clock_time) = {0}", clockDate)) > 0;
if (duplicated) {
throw new IllegalStateException("当前班次已存在打卡记录,请勿重复提交");
}
AttendanceRecord record = new AttendanceRecord();
record.setId(IdUtil.fastSimpleUUID());
record.setPersonId(loginUserId);
record.setPersonName("演示员工");
record.setUnitId("demo-unit");
record.setDepartmentId("demo-dept");
record.setClockType(dto.getClockType());
record.setUpDownWorkClock(dto.getUpDownWorkClock());
record.setClockTime(dto.getClockTime());
record.setLongitude(dto.getLongitude());
record.setLatitude(dto.getLatitude());
record.setClockAddress(resolveAddress(dto));
record.setClockImg(dto.getClockImg());
record.setAttendanceContent(dto.getAttendanceContent());
record.setAttendanceRule(dto.getRuleId());
record.setAttendanceWork(dto.getWorkId());
record.setClockStatus(matchClockStatus(dto));
record.setAppealStatus(0);
record.setCreateType(1);
record.setDelFlag(0);
this.save(record);
attendanceStatsService.rebuildDayStats(loginUserId, clockDate);
return "打卡成功";
}
@Override
public List<AttendanceRecordVO> dayRecord(String personId, LocalDate date) {
return baseMapper.selectDayRecord(personId, date);
}
/**
* 地址兜底。
* 真实项目中如果前端未传地址,会用经纬度反查地址;Demo 中只保留结构。
*/
private String resolveAddress(ClockInDTO dto) {
if (dto.getClockAddress() != null && !dto.getClockAddress().trim().isEmpty()) {
return dto.getClockAddress();
}
if (dto.getLongitude() != null && dto.getLatitude() != null) {
return "经纬度解析地址";
}
return "未知位置";
}
/**
* 判断打卡状态。
* 真实项目会结合班次时间、延迟分钟、请假、外勤审批等规则;Demo 只保留可复用骨架。
*/
private Integer matchClockStatus(ClockInDTO dto) {
if ("2".equals(dto.getClockType()) && dto.getAttendanceContent() == null) {
return 4;
}
return 0;
}
}
7. 日统计 Service:记录和结果分层
package com.demo.attendance.service;
import java.time.LocalDate;
/**
* 考勤日统计服务。
* 负责把多条打卡记录汇总为当天结果,避免列表页每次重新计算。
*/
public interface AttendanceStatsService {
/**
* 重算某员工某天统计。
*
* @param personId 员工ID
* @param date 日期
*/
void rebuildDayStats(String personId, LocalDate date);
}
package com.demo.attendance.service.impl;
import com.demo.attendance.service.AttendanceStatsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
/**
* 考勤日统计实现。
* Demo 只展示触发位置,真实项目可在这里统计迟到、早退、缺卡、外勤、请假和补卡次数。
*/
@Slf4j
@Service
public class AttendanceStatsServiceImpl implements AttendanceStatsService {
@Override
public void rebuildDayStats(String personId, LocalDate date) {
log.info("重算考勤日统计 personId={}, date={}", personId, date);
}
}
8. Controller:移动端提交和查询
package com.demo.attendance.controller;
import com.demo.attendance.dto.ClockInDTO;
import com.demo.attendance.service.AttendanceRecordService;
import com.demo.attendance.vo.AttendanceRecordVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.time.LocalDate;
import java.util.List;
/**
* 移动端考勤记录接口。
* 与真实项目的 `/app/kqAttendanceRecord` 职责一致:接收现场打卡、查询日记录。
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/app/attendanceRecord")
public class AppAttendanceRecordController {
private final AttendanceRecordService attendanceRecordService;
/**
* 员工打卡。
*
* @param dto 移动端采集的现场数据
* @return 结果提示
*/
@PostMapping("/clock")
public String clock(@RequestBody @Valid ClockInDTO dto) {
String loginUserId = "demo-user";
log.info("收到移动端打卡数据 userId={}, clockType={}, time={}",
loginUserId, dto.getClockType(), dto.getClockTime());
return attendanceRecordService.clock(dto, loginUserId);
}
/**
* 查询某天打卡记录。
*
* @param date yyyy-MM-dd
* @return 当天记录
*/
@GetMapping("/dayRecord")
public List<AttendanceRecordVO> dayRecord(@RequestParam String date) {
return attendanceRecordService.dayRecord("demo-user", LocalDate.parse(date));
}
}
9. PC 后台 API:列表、详情和编辑
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/biz/kqAttendanceRecord/list',
detail = '/biz/kqAttendanceRecord/queryById',
update = '/biz/kqAttendanceRecord/edit',
exportXls = '/biz/kqAttendanceRecord/exportXls',
}
/**
* PC 后台考勤记录列表。
* 用于管理员按人员、部门、打卡状态和时间范围查询。
*/
export const listAttendanceRecord = (params) => {
return defHttp.get({ url: Api.list, params });
};
/**
* 查询单条考勤详情。
*/
export const getAttendanceRecordDetail = (params) => {
return defHttp.get({ url: Api.detail, params });
};
/**
* 管理端修正记录。
* 实际项目里建议所有修正都走审批或留审计日志,避免直接改结果。
*/
export const updateAttendanceRecord = (params) => {
return defHttp.put({ url: Api.update, params });
};
export const exportAttendanceRecordUrl = Api.exportXls;
10. uni-app 移动端调用
真实项目的 `api.js` 里有:
const API = {
clock: "/zhkq-api/app/kqAttendanceRecord/clock",
dayRecord: "/zhkq-api/app/kqAttendanceRecord/dayRecord",
monthRecord: "/zhkq-api/app/kqAttendanceRecord/monthRecord",
abnormalList: "/zhkq-api/app/kqAttendanceRecord/abnormalList"
}
脱敏后的页面调用可以这样写:
function submitClock(clockInfo, location, fileList) {
const formData = {
ruleId: clockInfo.ruleId,
workId: clockInfo.workId,
clockType: clockInfo.clockType,
upDownWorkClock: clockInfo.upDownWorkClock,
clockTime: new Date().toISOString().slice(0, 19).replace('T', ' '),
longitude: location.longitude,
latitude: location.latitude,
clockAddress: location.address,
clockImg: fileList.map(file => file.url).join(','),
attendanceContent: clockInfo.remark
};
return http.post('clock', formData).then(() => {
uni.showToast({ title: '打卡成功' });
});
}
三、这套 Demo 对应真实项目里的技术特色
第一,移动端只负责采集,不负责最终裁判。
uni-app 页面会采集时间、经纬度、图片、外勤说明、规则 ID 和班次 ID,但最终是否迟到、早退、旷工、缺卡,应该由后端统一判断。真实项目中还会校验手机系统时间,避免员工把时间调到未来。
第二,记录必须保留规则快照。
`attendanceRule` 和 `attendanceWork` 看起来只是两个 ID,但非常关键。员工 7 月 1 日打卡时命中的规则,和 7 月 10 日管理员修改后的规则可能不是一回事。历史记录必须能解释当时为什么判定正常或异常。
第三,异常不是后台直接改字段。
真实项目里 `afStatus` 用来表示申诉状态。一个异常考勤记录如果直接被管理员改成正常,短期省事,长期会失去可信度。更好的方式是:员工发起申诉,主管审批,审批通过后再回写记录和日统计。
第四,记录和统计要分层。
`AttendanceRecord` 是事件,`AttendanceStats` 是结果。一个员工一天可能有上班卡、下班卡、外勤卡、补卡、请假记录。如果列表页每次都实时计算,查询会越来越重。更稳的方式是记录变动后重算当天统计。
第五,后台任务负责兜底。
智慧考勤项目里有 XXL-Job 生成缺卡、旷工、历史回算等任务。移动端没有提交,不代表当天没有考勤结果。系统必须在关键时间点自动补偿,否则月底就会变成 HR 手工补账。
四、迁移到其他企业系统时怎么复用
这条链路可以抽象成一句话:
现场采集事件,后端验证规则,流程处理异常,统计沉淀结果,后台任务补偿缺口。
巡检系统可以这样迁移:
- `AttendanceRecord` 换成 `InspectionRecord`
- `clockType` 换成 `inspectionType`
- `attendanceRule` 换成 `inspectionPlan`
- `clockImg` 保留为现场照片
- `AttendanceStats` 换成巡检日报
- 缺卡任务换成漏检任务
设备保养系统也可以这样迁移:
- 规则表定义保养周期
- 移动端提交保养位置、照片和说明
- 后端判断是否超期
- 异常进入审批
- 定时任务生成逾期保养记录
- PC 后台按设备、人员、区域做统计
五、落地时最容易踩的坑
-
不要让移动端直接传"正常/迟到/旷工"结果。移动端可以给建议状态,最终状态必须由后端判断。
-
不要只存当前组织名称。人员调岗后,历史统计还要能按当时部门解释。
-
不要把异常处理做成管理员后台手工改字段。必须留申请、审批、回写和审计。
-
不要只做打卡流水,不做日统计。数据量一大,报表会越来越慢。
-
不要忽略定时任务。缺卡、旷工、超时、漏检这类结果,很多时候不是用户主动提交出来的,而是系统补偿生成的。
六、结论
智慧考勤的可复用价值不在"打卡"两个字,而在它把企业系统里最难处理的几件事串起来了:
- 规则配置
- 现场采集
- 证据留存
- 后端裁判
- 异常流程
- 统计回算
- 定时补偿
如果你在做巡检、外勤、工单、门店、设备保养或现场服务系统,可以直接复用这套工程骨架。页面可以变,业务名可以变,但"事件、规则、流程、统计、补偿"这五层不要省。省掉哪一层,后面都会在对账、争议和报表里补回来。