☰
Spring Boot人事档案管理系统:Excel批量导入导出与审批流
2026/10/27 3:43:28 网站建设 项目流程

简介:这是一套面向人事行政、档案管理与中小企业办公场景的人事档案管理系统,标题标注破解版,使用前需自行评估授权与安全风险;其界面简洁、操作门槛低,并支持按需自定义字段与显示方式。压缩包约3.16MB,文件总数显示为0,主要类型明细暂无数据,下载后可按目录结构取用;描述显示其免安装,兼顾单机与网络环境。系统提供查询打印、CSV/XLS等格式导入导出、摄像头数据采集,以及学历、性别、年龄、籍贯、婚姻状况、毕业院校等多维统计和图表展示;员工生日提醒、身份证信息校验与出生日期/性别/年龄/籍贯自动识别、树型分组等功能,便于快速透视和整理档案。已有1438人学习或下载,适合需要低成本搭建人事信息台账、做批量数据导入导出和个性化报表定制的读者参考使用。

1. 一份 3000 行的花名册:这套人事档案管理系统先解决的是「数据集中管理」

某公司的行政每月要收十来个部门的花名册,字段名各不相同:有的写「入职时间」,有的写「到岗日期」,还有的把身份证号存成了科学计数法。这套人事档案管理系统要做的第一件事是集中管理:把散在各处的表格统一到一套库表结构里——员工主档管工号、部门、在职状态,档案扩展表管理合同、学历、紧急联系人,再配一条支持导入导出的批量通道,新人入职时传一份 Excel 就能建档。它适合两类人:几十到几百人规模、还在用表格管人的行政人事团队;以及想找带完整增删改查 + Excel 批量处理的全栈练手项目的开发者。功能不花哨,但每个模块都能对应到真实业务字段,改起来有余地。

2. 技术栈与目录结构:一次员工列表查询会经过哪些分层

2.1 选型:为什么是 Spring Boot + MyBatis-Plus + Vue 这套组合

人事档案这类系统的业务特征是「单表字段多、查询条件杂、批量操作频繁」,选型基本被这三点决定。

后端用 Spring Boot,最直接的理由是部署形态。内嵌容器打成一个可执行 jar,服务器上java -jar就起来了,不用先装 Tomcat 再往里丢 war 包。对这种中小型管理系统,少一层容器配置就少一类「本地能跑服务器跑不了」的问题。

持久层选 MyBatis-Plus 而不是 JPA,是查询条件逼出来的。员工列表的筛选条件经常变:部门、在职状态、入职时间区间、姓名/工号模糊匹配,后期还会加「合同 30 天内到期」。用方法名派生查询,条件一多就得写 Specification,拼起来绕;MyBatis-Plus 的LambdaQueryWrapper按需eq、like、between,加条件就是加一行,分页注册一个PaginationInnerInterceptor就够。单表增删改查也不用写 XML,只有导入导出那种多表关联查询才落到 mapper xml 里。

Excel 处理选 EasyExcel 而不是原生 POI,这一条和后面的踩坑章节直接相关。POI 的XSSFWorkbook是全量加载,用户传个两万行花名册,堆内存小的机器直接 OOM;EasyExcel 基于 SAX 逐行回调,内存占用和总行数基本无关。代价是只能顺序读、拿不到随机单元格,但对导入场景完全够用。

前端 Vue + Element UI,因为后台管理页面 90% 的结构是「搜索栏 + 表格 + 分页 + 弹窗表单」,el-table、el-form、el-upload拿来即用,比手写组件省下大量时间。

层次选型在这套系统里承担什么
后端框架Spring Boot 2.xREST 接口、内嵌容器、统一异常处理
持久层MyBatis-Plus单表 CRUD、条件构造、分页插件
数据库MySQL员工主档、档案扩展字段、附件元数据
ExcelEasyExcel流式读写,导入导出不爆内存
前端Vue + Element UI列表页、档案详情、导入弹窗
登录态JWT + 拦截器接口鉴权、角色菜单

2.2 目录结构:分包边界决定了后面好不好改

很多人拿到这类源码,第一反应是直接在 controller 里改逻辑,改到第三个需求就发现动一处崩三处。先看清它怎么分的包:

hr-archive/ ├── hr-admin/ # 启动模块,只放启动类和全局配置 │ └── src/main/java/.../HrApplication.java ├── hr-common/ # 通用返回体 R、业务异常、日期工具 │ └── src/main/java/.../common/ ├── hr-system/ # 业务主体,员工/部门/档案都在这 │ ├── domain/ # 实体类,和表一一对应 │ ├── mapper/ # Mapper 接口 + resources/mapper 下的 xml │ ├── service/ # 业务逻辑,导入导出都在这一层 │ └── controller/ # 只收参数、调 service、包返回值 ├── hr-web/ # 前端 Vue 工程 │ ├── src/api/employee.js # 按模块拆的接口封装 │ ├── src/views/employee/ # 列表、详情、导入弹窗 │ └── src/utils/request.js # axios 拦截器,统一塞 token、统一处理 401 └── sql/ # schema.sql 与 data.sql

分层的核心约定只有一条:controller 不写业务判断。它负责@RequestBody接参、@Validated触发校验、调 service、返回R.ok(data),超过这个范围的东西一律下沉。

这条约定在后面加功能时价值最大。比如要加一条「离职必须走审批」,改动落在 service 的状态流转方法上,接口签名不变,前端只多绑一个按钮。如果当初把状态判断写在 controller 里,加流程就得同时改三个接口。

hr-common单独拆出来的理由也类似。返回体不统一是这类项目最常见的退货原因——有的接口返回{code, msg, data},有的直接返对象,前端拦截器没法统一处理。把R类收到 common 里,所有模块只能用它,这个问题在编译期就没了。

2.3 表结构:主档、扩展字段、附件三张表怎么分

表设计的取舍点在于「档案字段到底加列还是加行」。人事系统的字段是会一直长的:今天加「是否有残疾证」,明天加「人才引进落户时间」。每来一个就ALTER TABLE,加十次之后主表四十个字段,一半是 NULL,查询计划也难看。

常见做法是拆成主档 + 纵向扩展表:

-- 员工主档:只放高频查询、几乎不变的字段 CREATE TABLE emp_employee ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', emp_no VARCHAR(32) NOT NULL COMMENT '工号,业务唯一键', name VARCHAR(64) NOT NULL COMMENT '姓名', dept_id BIGINT NOT NULL COMMENT '所属部门', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在职 2试用 3离职', hire_date DATE NULL COMMENT '入职日期', del_flag TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除', PRIMARY KEY (id), UNIQUE KEY uk_emp_no (emp_no), KEY idx_dept_status (dept_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工主档'; -- 档案扩展字段:竖表,一个员工一条属性一行 CREATE TABLE emp_archive_item ( id BIGINT NOT NULL AUTO_INCREMENT, emp_id BIGINT NOT NULL COMMENT '员工主键', item_code VARCHAR(64) NOT NULL COMMENT '字段编码,如 CONTRACT_END', item_value VARCHAR(512) NULL COMMENT '字段值,统一按字符串存', PRIMARY KEY (id), UNIQUE KEY uk_emp_item (emp_id, item_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='档案扩展字段';

竖表的代价要说清楚:查一个员工的完整档案要按emp_id拿一批行,再在 service 里转成 Map 给前端,比直接SELECT *多一步;也没法对item_value做范围查询和类型约束,日期得在应用层转。换来的是加字段零 DDL——在字典表里插一行「合同到期日」,前端配置页自然就出现了,导入模板也跟着多一列。

uk_emp_item这个唯一键不能省。导入时同一个员工同一字段出现两行,有唯一键就是报错提示,没唯一键就是无声地留下两条脏数据,后面谁也不知道该信哪条。

附件表按常规写法:emp_id、原始文件名、存储路径、文件大小、上传时间。文件本身不要存进数据库的 blob 字段——备份和迁移会非常痛苦,落到磁盘或对象存储、库里只留路径,是这类系统里几乎不会出错的解法。

3. 导入导出流程:从 Excel 模板到批量写入的完整实现

3.1 导入的模板和实体对应关系

导入失败的案例里,一大半问题出在「模板列」和「实体字段」之间那一层映射没做好。用户手里的模板可能是行政自己改过的:列顺序换了,中间插了一列备注,表头写成「入职日期(必填)」。

稳妥的做法是给导入单独建一个 DTO,用@ExcelProperty按表头名字绑定,而不是按列下标:

// 导入专用 DTO,和数据库实体分开,避免把校验注解污染到实体上 @Data public class EmpImportRow { @ExcelProperty("工号") private String empNo; @ExcelProperty("姓名") private String name; @ExcelProperty("部门") private String deptName; // 模板里写部门名,入库前换成 deptId @ExcelProperty("入职日期") private Date hireDate; @ExcelProperty("手机号") private String phone; }

按表头名绑定后,用户调整列顺序不影响解析;但如果他把表头文字改了,比如把「工号」写成「员工编号」,这一列就绑不上,值是 null。所以导入接口的第一件事不是读数据,是先校验表头集合,缺哪列直接返回给前端,别让用户等到导入 3000 行才看见一片「工号不能为空」。

第 3 章要讲的第二条:模板里放部门名而不是部门 ID,是对的选择。填表的人(行政、部门助理)根本不知道系统内部的部门 ID 是多少,让他去查 ID 等于让导入功能没法用。代价是后端要做一次名称到 ID 的转换,并且要处理「部门名字打错一个字」——这个失败必须在逐行校验阶段就拦住。

3.2 批量写入:监听器每 500 条写入一次

EasyExcel 的读模型是回调式:每解析完一行调一次invoke,全部读完调doAfterAllAnalysed。业务逻辑就挂在这两个方法上。

// 导入监听器:每读完一行回调一次,攒够 BATCH_SIZE 就落一次库 public class EmpImportListener extends AnalysisEventListener<EmpImportRow> { private static final int BATCH_SIZE = 500; private final List<EmpImportRow> cache = new ArrayList<>(BATCH_SIZE); private final List<String> errors = new ArrayList<>(); private final EmpService empService; public EmpImportListener(EmpService empService) { this.empService = empService; } @Override public void invoke(EmpImportRow row, AnalysisContext context) { // getRowIndex 从 0 开始,+1 之后才和用户在 Excel 里看到的行号对上 int rowNo = context.readRowHolder().getRowIndex() + 1; String err = empService.validateRow(row); if (err != null) { // 单行不合法只记录,不中断整批:用户要的是一次看到所有问题行 errors.add("第 " + rowNo + " 行:" + err); return; } cache.add(row); if (cache.size() >= BATCH_SIZE) { flush(); } } private void flush() { empService.saveBatchFromImport(cache); cache.clear(); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 最后一批可能不足 500 条,别漏掉 if (!cache.isEmpty()) { flush(); } } public List<String> getErrors() { return errors; } }

BATCH_SIZE取 500 是个经验值:太小则每条都发一次 SQL,三千行的文件要跑几分钟;太大则单条 insert 语句过长,MySQL 的max_allowed_packet容易被打爆,而且一条失败整批回滚,用户重传的成本变高。两千到五千人的公司,500 这个档位基本不用调。

errors用 List 收集而不是遇到错误就throw,是有意的设计。人事导入的典型场景是用户从别的系统导出一份数据,里面混着十几条格式不对的——如果第一条就抛异常整批失败,用户改一条传一次,来回十几次。攒着一次性回写,用户体验完全不同。

落库那一步要处理唯一键冲突:

public void saveBatchFromImport(List<EmpImportRow> rows) { List<EmpEmployee> list = rows.stream().map(r -> { EmpEmployee e = new EmpEmployee(); e.setEmpNo(r.getEmpNo().trim()); // 手工录入的工号前后带空格很常见 e.setName(r.getName().trim()); e.setDeptId(deptMap.get(r.getDeptName())); // 校验阶段已确认能取到 e.setHireDate(r.getHireDate()); e.setStatus(1); return e; }).collect(Collectors.toList()); // 工号重复走 upsert,不用整批 insert 硬撞唯一键 empMapper.insertOrUpdateBatch(list); }

insertOrUpdateBatch对应INSERT ... ON DUPLICATE KEY UPDATE。如果直接insertBatch,整批里只要有一个工号和库里重复,这条语句就整体失败,前面攒的 499 条白读了。用 upsert 的语义是「导入即更新」,符合人事的实际用法:新员工建档、老员工补字段,都是同一份模板。

3.3 导出:分页查询配合单个 ExcelWriter 追加写入

导出的坑集中在内存上。最直观的写法是empService.list(query)查出全部再EasyExcel.write(...).sheet().doWrite(list),五千行时看不出来,导全公司的历史档案(含离职)到三万行,服务端的堆就吃紧了。

改成边查边写:

public void export(HttpServletResponse response, EmpQuery query) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); // 中文文件名必须编码,否则浏览器存下来是乱码 String fileName = URLEncoder.encode("员工档案", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); try (ExcelWriter writer = EasyExcel.write(response.getOutputStream(), EmpExportRow.class).build()) { WriteSheet sheet = EasyExcel.writerSheet("员工档案").build(); int pageNo = 1, pageSize = 2000; while (true) { // 每页查完立刻写进同一个 writer,内存里只留一页 Page<EmpExportRow> page = empMapper.selectExportPage(new Page<>(pageNo, pageSize), query); List<EmpExportRow> records = page.getRecords(); if (records.isEmpty()) { break; } writer.write(records, sheet); if (records.size() < pageSize) { break; // 最后一页,提前跳出,省一次空查询 } pageNo++; } } }

三个参数值得单独说:pageSize取 2000 是查询效率和内存的平衡点,再大单次查询就容易超过慢查询阈值;ExcelWriter只build()一次,所有页复用它,如果每页都新建 writer,最终只有一个 sheet 的内容能留下;response.getOutputStream()拿到的流由 writer 接管,不要再包一层BufferedOutputStream手动 flush,容易在收尾时抛「stream closed」。

导出还有个容易被忽略的点:导出字段和列表页字段不是一回事。列表页为了加载快只查十几个字段,导出往往要带部门名、上级主管名这些关联字段。所以导出走独立的 mapper 方法和独立的EmpExportRow,别图省事复用列表的查询。

4. 本地运行:环境变量、建库脚本与首屏验证

4.1 环境准备:JDK、Node、MySQL 的版本底线

三件套版本先对齐,这一步出问题后面全是玄学:

  • JDK 8 或 11。Spring Boot 2.x 在 8 上跑得最稳,用 17 也能起,但部分老依赖的反射会警告。
  • Node 14 以上。老版本 Vue CLI 项目在 Node 17+ 上可能因为 OpenSSL 报ERR_OSSL_EVP_UNSUPPORTED,遇到就把 Node 降到 16 用。
  • MySQL 5.7 或 8.0。8.0 要注意连接串必须带serverTimezone,否则日期差 8 小时。

版本确认不用猜,直接跑:

java -version node -v mysql --version

三个命令的输出记下来,后面报错时第一件事就是回来对一遍。

4.2 配置项:数据源、上传大小、跨域三段必须改

配置文件里能直接跑的部分不多,这三段基本都要动:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/hr_archive?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root # 密码不要写死在文件里,用环境变量兜底,本地起的时候 export 一下 password: ${DB_PASSWORD:root} servlet: multipart: # 默认 1MB,传个带附件的花名册直接 500,这条必须调 max-file-size: 20MB max-request-size: 20MB mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true app: cors: allowed-origins: http://localhost:8081 # 前端 dev server 的地址

multipart那两行是最容易漏的。默认单文件 1MB,用户传一份三万行的 xlsx 会得到MaxUploadSizeExceededException,前端只显示「上传失败」,排查半天才发现是配置。20MB 对纯文本花名册足够,带扫描件的另说。

allowed-origins不要写*再配allowCredentials=true,浏览器会直接拒绝这个组合。开发期就老老实实写前端的实际地址。

4.3 建库与启动:四条命令跑起来

# 1. 建库,字符集必须 utf8mb4,否则姓名里的生僻字存不进去 mysql -uroot -p -e "CREATE DATABASE hr_archive DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 建表 + 初始数据(部门、字典、一个管理员账号) mysql -uroot -p hr_archive < sql/schema.sql mysql -uroot -p hr_archive < sql/data.sql # 3. 启动后端,跳过测试能省两分钟 cd hr-admin && mvn clean package -DskipTests java -jar target/hr-admin.jar # 4. 另开一个终端起前端 cd hr-web && npm install && npm run serve

前端 dev server 起来后会打印实际端口,常见是 8081(8080 被后端占了)。如果打印的不是配置里allowed-origins写的那个,改配置或者改端口,两边对齐再往下走。

提示:mvn clean package第一次会拉一堆依赖,网络慢的时候卡在Downloading是正常现象,别以为是卡死重来。

4.4 首屏验证:五步确认导入导出真的能用

接口通了不等于功能能用,按这五步走一遍,能覆盖 80% 的集成问题:

  1. 用初始管理员账号登录,能拿到 token 并跳转到员工列表页。跳不过去先看request.js里 token 塞进了哪个 header,再看后端拦截器读的是不是同一个名字。
  2. 列表页能看到初始数据里的几条员工记录,分页组件显示的总数不为 0。
  3. 下载导入模板,随便填三条:一条正常、一条工号为空、一条部门名写错,上传。预期返回「第 2 行:工号不能为空」「第 3 行:部门【XX】不存在」,而不是整批失败。
  4. 把三条改成全对再传一次,列表里出现三条新记录,工号没有重复。
  5. 点导出,下载到的 xlsx 打开后中文不乱码、日期列是日期格式而不是45123这种序列号。

第 3 步和第 5 步是这套系统最容易翻车的地方,跑通这两条,导入导出这条链路就算立住了。

5. 避坑与排查:五类会直接破坏导入导出的现场

5.1 现象:导入成功但中文全变问号

现象:文件上传成功,提示「导入 50 条」,但列表里员工姓名显示成??或者乱码方块。

原因:链路上有一个环节的字符集不是 utf8。它可能来自三处:数据库建库时用了默认的 latin1;连接串里没写characterEncoding=utf8;或者表本身是 utf8 但列是旧版遗留的 utf8(三字节),存不下四字节的生僻字。

解决:按「库 → 表 → 连接」的顺序查。执行SHOW CREATE DATABASE hr_archive;看库的字符集,SHOW FULL COLUMNS FROM emp_employee;看列的 collation。全链路统一到utf8mb4/utf8mb4_general_ci。连接串补上useUnicode=true&characterEncoding=utf8,注意这里写utf8就够,驱动会自动协商到 utf8mb4。

5.2 现象:导入三千行就超时,日志里一片连接等待

现象:小文件(几十行)传得好好的,三千行的花名册传到一半前端 504,后端日志刷HikariPool - Connection is not available, request timed out。

原因:监听器被写成了一个方法内部new的局部对象,这没错;错在flush()里逐条empService.save(row),三千行就是三千次独立事务,每次都要拿连接、提交、归还。连接池默认 10 个连接,全被这三千次串行等待拖住,加上中间还有部门查询,很快就耗尽。

解决:改批量。一是把save换成 MyBatis-Plus 的saveBatch(list)或者自定义的insertOrUpdateBatch,一次事务写 500 条;二是如果每行都要查部门名换 ID,把部门表在导入开始前一次性读进Map<String, Long>缓存,别在循环里查库。改完三千行通常在两三秒内完成。

5.3 现象:导出五万行时服务直接重启

现象:点导出,浏览器转圈十几秒后连接断开,去看服务进程已经没了,日志末尾是java.lang.OutOfMemoryError: Java heap space。

原因:EasyExcel.write(...).doWrite(list)里的 list 是empService.list()的全量结果,五万个实体对象加上 EasyExcel 内部缓冲,默认堆(通常 512M 到 1G)撑不住。另外XSSFWorkbook系的写法即使分批也会在内存里堆 sheet 数据。

解决:换成 3.3 那套「分页查 + 单 Writer 追加写」。如果还紧张,给 JVM 加-Xmx1g,但优先改代码——把内存问题交给堆参数,只是把崩溃点往后推。另外排查一下导出 SQL 有没有SELECT *带出用不到的 blob 或长文本字段,把列裁到导出模板实际需要的那些。

5.4 现象:登录成功,刷新页面就 401

现象:登录接口返回了 token,前端也存进了 localStorage,但只要刷新页面,所有请求都是 401,重新登录又好了。

原因:axios 拦截器在应用初始化时读取 token,而 localStorage 的读取被放在某个组件的mounted里,刷新后请求比读取先发出,header 是空的。另一种情况是后端拦截器读的 header 名是Authorization,前端塞的是token。

解决:token 的读取放在请求拦截器内部,每次请求现取,不依赖任何初始化时序:

// request.js:每次请求现取 token,不缓存 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })

然后和后端拦截器里的request.getHeader("Authorization")对齐,注意Bearer前缀要一起处理,别一边加一边不加。

5.5 现象:改一个 URL 参数就能看到别人的档案

现象:档案详情接口是GET /emp/{id},把 URL 里的 id 从 10 改成 11,直接返回了另一个员工的完整信息,包括身份证号和手机号。

原因:接口只校验了「有没有登录」,没校验「有没有权限看这条数据」。这是这类后台系统最常见的漏洞,菜单权限做了、按钮权限做了,但数据权限没做。

解决:在 service 层加数据范围判断,不要指望前端隐藏按钮。普通员工只能查自己(id == currentUserId),部门主管只能查本部门及下级,人事和管理员不受限。判断逻辑集中写在一个DataScopeInterceptor或 service 的切面里,别散落在每个查询接口——散着写一定会漏。另外身份证、手机号这类敏感字段在列表接口里做脱敏,详情接口单独鉴权后再返原文。

注意:这条不是「优化建议」,是上线前必须堵的口子。人事档案里的身份证号、家庭住址、薪资,泄露一次就是实打实的事故。

6. 二次开发:在员工主档上接入一套离职审批流

拿到源码后最典型的改造需求是加审批。离职流程是验证这套骨架好不好改的最佳样本,因为它同时碰到状态流转、权限和数据一致性。

改造分三步。第一步加表:emp_approval存id、emp_id、type(离职/转正/调岗)、status(待审/通过/驳回)、applicant_id、approver_id、remark、create_time。第二步在EmpService里加一个方法,用状态机控制流转,别让 controller 直接改status:

@Transactional(rollbackFor = Exception.class) public void approveLeave(Long approvalId, boolean pass, String remark) { EmpApproval ap = approvalMapper.selectById(approvalId); // 只有待审状态能推进,防止重复点按钮造成二次审批 if (ap == null || ap.getStatus() != 0) { throw new BizException("该申请已被处理"); } // 审批人必须是当前登录人,别信前端传的 approverId if (!ap.getApproverId().equals(SecurityUtils.getUserId())) { throw new BizException("无权审批"); } ap.setStatus(pass ? 1 : 2); ap.setRemark(remark); approvalMapper.updateById(ap); if (pass) { // 审批通过才动主档,且用乐观更新避免并发覆盖 empMapper.updateStatus(ap.getEmpId(), 3, ap.getEmpId()); } }

第三步前端在员工详情页加「发起离职」按钮,在待办列表页加审批入口。

验证方法我自己固定走一遍:准备三个账号——普通员工、部门主管、管理员。员工发起离职,主管登录看到待办、点通过,管理员刷新员工列表确认状态变成「离职」;再用同一个审批单点第二次通过,必须报「该申请已被处理」;最后用普通员工账号直接调POST /approval/approve,必须被拦。三个都过,流程才算真的接上。

血泪经验是那个updateStatus一定要带状态条件(WHERE id = ? AND status IN (1,2)),我第一次写的时候漏了,两个主管同时点通过,主档被更新两次,日志里查了半天才定位到是并发。从那以后我每次加状态流转,都强制把「当前状态作为更新条件」写进 SQL,宁可多一条失败日志,也不要一条重复生效的脏数据。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询