Spring Boot + uni-app 智慧考勤闭环 Demo:打卡记录、异常状态和日统计如何复用到企业系统

这篇不讲"企业系统应该数字化"这种空话,直接从智慧考勤项目里抽一条可以复用的工程链路:移动端采集打卡数据,后端按规则落库,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 后台按设备、人员、区域做统计

五、落地时最容易踩的坑

  1. 不要让移动端直接传"正常/迟到/旷工"结果。移动端可以给建议状态,最终状态必须由后端判断。

  2. 不要只存当前组织名称。人员调岗后,历史统计还要能按当时部门解释。

  3. 不要把异常处理做成管理员后台手工改字段。必须留申请、审批、回写和审计。

  4. 不要只做打卡流水,不做日统计。数据量一大,报表会越来越慢。

  5. 不要忽略定时任务。缺卡、旷工、超时、漏检这类结果,很多时候不是用户主动提交出来的,而是系统补偿生成的。

六、结论

智慧考勤的可复用价值不在"打卡"两个字,而在它把企业系统里最难处理的几件事串起来了:

  • 规则配置
  • 现场采集
  • 证据留存
  • 后端裁判
  • 异常流程
  • 统计回算
  • 定时补偿

如果你在做巡检、外勤、工单、门店、设备保养或现场服务系统,可以直接复用这套工程骨架。页面可以变,业务名可以变,但"事件、规则、流程、统计、补偿"这五层不要省。省掉哪一层,后面都会在对账、争议和报表里补回来。

相关推荐
kuro-shiro2 小时前
SpringBoot 启动流程
java·spring boot·后端
海兰2 小时前
【SpringBoot 】AOP企业级权限控制方案(二)
android·java·spring boot
LiaoWL1232 小时前
【SpringBoot合集-03】Spring Boot 启动过程学习
java·spring boot·学习
这是个栗子2 小时前
uni-app 微信小程序开发:常用事件指令(@xxx)(一)
微信小程序·小程序·uni-app
宠友信息12 小时前
多端数据互通场景下Spring Boot仿小红书源码结构设计
数据库·spring boot·redis·缓存·架构
不会c+12 小时前
02-SpringBoot配置文件
java·spring boot·后端
格子软件15 小时前
2026年GEO优化系统源码解构:核心状态机与高并发流控深度剖析
java·vue.js·spring boot·vue·geo
Flittly16 小时前
【AgentScope Java新手村系列】(17)长期记忆系统
java·spring boot·spring
SeeYa-J17 小时前
Sprint 1-2:创建第一个 Spring Boot Module(user-service)
java·spring boot·sprint