启用禁用员工账号与 Long 型 ID 精度丢失修复
纲要
这篇记录员工账号启用 / 禁用功能的实现,顺手解决一个藏得比较深的线上问题:Long 型雪花 ID 传到前端后被抹平精度,导致更新不生效。
- 功能本质 :修改
employee.status字段,是一个UPDATE操作 - 接口契约 :
PUT /employee,入参{"id": ..., "status": ...} - 通用
update方法:启用禁用与编辑员工复用同一个方法 - 权限可见性 :只有
admin能看到启用禁用按钮,靠v-if判断 - 缺陷现象 :功能「不报错但也不生效」,
UPDATE的WHERE id = ?与库中ID不一致 - 根因 :
Long型雪花ID19 位,超出JavaScript安全整数范围2^53,精度丢失 - 修复方案 :自定义
JacksonObjectMapper,Long序列化为字符串;注册到MVC消息转换器队首
需求与页面分析
按钮的可见性控制
进入员工管理页会发现,只有 admin 账号能看到「启用 / 禁用」按钮,其他员工看不到。这由前端 v-if 控制:
html
<el-table-column label="操作" width="160" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="addMemberHandle(scope.row.id)">编辑</el-button>
<!-- v-if:仅当登录用户为 admin 时才渲染启用/禁用按钮 -->
<el-button
v-if="user === 'admin'"
type="text"
size="small"
@click="statusHandle(scope.row)"
>
{{ String(scope.row.status) === '0' ? '启用' : '禁用' }}
</el-button>
</template>
</el-table-column>
user 取自 localStorage 中保存的登录用户名:
javascript
created() {
this.user = JSON.parse(localStorage.getItem('userInfo')).username
}
注意这只是前端可见性控制 ,不是权限控制。真正的权限校验必须在后端做 ------ 前端判断可以被绕过(直接构造 HTTP 请求即可)。本项目未做后端权限校验,生产环境必须补上。
按钮状态的反转逻辑
按钮文字与当前状态是反的:账号正常时显示「禁用」,账号禁用时显示「启用」。
javascript
{{ String(scope.row.status) === '0' ? '启用' : '禁用' }}
请求参数的三目运算
点击按钮后执行 statusHandle:
javascript
statusHandle(row) {
this.$confirm('确认调整该账号的状态?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 关键:status 取当前值的反转
enableOrDisableEmployee({
id: row.id,
status: !row.status ? 1 : 0
}).then(res => {
if (String(res.code) === '1') {
this.$message.success('账号状态更改成功!')
this.handleQuery()
}
})
})
}
status: !row.status ? 1 : 0 的含义:
当前 status |
!row.status |
提交的 status |
语义 |
|---|---|---|---|
1(正常) |
false |
0 |
禁用 |
0(禁用) |
true |
1 |
启用 |
member.js 中的接口封装:
javascript
function enableOrDisableEmployee(params) {
return $axios({
url: '/employee',
method: 'put',
data: { ...params }
})
}
请求特征:
text
PUT http://localhost:8080/employee
Request Payload: {"id":"1420038345634918401","status":0}
后台实现
为什么写通用 update 而不是 updateStatus
启用禁用本质上只是修改 status 一个字段。如果写成 updateStatus(Long id, Integer status),后续「编辑员工信息」功能还要再写一个 update(Employee) ------ 两者底层都是 UPDATE employee SET ... WHERE id = ?,完全重复。
统一成一个 update 方法,两个功能复用:
启用/禁用员工
{id, status}
PUT /employee
update(Employee)
编辑员工信息
{id, name, phone, ...}
employeeService.updateById(employee)
UPDATE employee SET ... WHERE id = ?
方法实现
java
/**
* 根据id修改员工信息
* @param request
* @param employee
* @return
*/
@PutMapping
public R<String> update(HttpServletRequest request, @RequestBody Employee employee) {
log.info(employee.toString());
long id = Thread.currentThread().getId();
log.info("线程id为:{}", id);
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}
关键点
@PutMapping 不写路径 :类上已有 @RequestMapping("/employee"),前端 PUT /employee 正好匹配。
@RequestBody Employee :只提交了 id 与 status,Employee 对象的其他属性都是 null。这恰恰是关键 ------ MyBatis-Plus 的 updateById 默认只更新非 null 字段 ,所以不会把 name、phone 等误置空。
这是 MyBatis-Plus 的默认行为(FieldStrategy.NOT_NULL)。若想让某字段能被显式更新为 null,需在该字段上加 @TableField(updateStrategy = FieldStrategy.IGNORED)。
Thread.currentThread().getId() 的用途 :这行日志是为了验证「一次请求从头到尾由同一个线程处理」------ 这是 BaseContext(ThreadLocal)能正确工作的前提。调试时观察同一个请求中过滤器、控制器、MetaObjectHandler 打印的线程 ID 是否一致。
生成的 SQL
text
==> Preparing: UPDATE employee SET status=?, update_time=?, update_user=? WHERE id=?
==> Parameters: 0(Integer), 2021-07-23 11:20:33(LocalDateTime), 1(Long), 1420038345634918401(Long)
缺陷:Long 精度丢失
现象
点击禁用,页面提示「账号状态更改成功」,但刷新后状态没变。控制台没有任何异常 ,SQL 正常执行,Updates: 0。
仔细观察 SQL 参数:
text
数据库里的 id : 1420038345634918402
SQL 里的 id : 1420038345634918400
^^^ 末位被抹平
WHERE id = 1420038345634918400 匹配不到任何记录,所以 Updates: 0,功能静默失效。
根因
JavaScript 的 Number 类型采用 IEEE 754 双精度浮点表示,能精确表示的整数范围是:
text
[-2^53 + 1, 2^53 - 1] 即 [-9007199254740991, 9007199254740991]
而雪花算法生成的 ID 是 19 位,远超 9007199254740991(16 位)。超出的部分会被四舍五入到最接近的可表示值。
在浏览器控制台可以直接复现:
javascript
console.log(1420038345634918402)
// 输出:1420038345634918400
console.log(1420038345634918402 === 1420038345634918400)
// 输出:true ← 两个不同的数被判定为相等!
数据流转中的精度丢失点
JavaScript Jackson 默认序列化 Java (Long) MySQL JavaScript Jackson 默认序列化 Java (Long) MySQL 实际得到 1420038345634918400 id = 1420038345634918402 (bigint) Long 类型,值完整 {"id":1420038345634918402} ← 数字形态 JSON.parse → Number 精度丢失 PUT 请求带回失真的 ID UPDATE ... WHERE id = 1420038345634918400 影响 0 行
精度是在 JavaScript 解析 JSON 这一步丢的。所以最直接的修法,就是在 Jackson 序列化时把 Long 写成字符串,JavaScript 按字符串处理,就不会走数值解析这条路。
三类受影响的操作
| 操作 | 表现 |
|---|---|
| 启用 / 禁用 | WHERE id 匹配不到,Updates: 0 |
| 编辑 | 同上,修改不生效 |
| 删除 | 同上,删除不生效 |
这个缺陷坑人的地方在于:页面看起来一切正常,不报错,只是功能不生效 。不知道 JavaScript 数字精度限制的话,排查起来会绕不少弯路,因为从后端日志到 SQL 都看不出明显问题。
修复方案
第一步:自定义对象映射器
src/main/java/com/itheima/reggie/common/JacksonObjectMapper.java:
java
package com.itheima.reggie.common;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
// 核心:Long / BigInteger 序列化为字符串,避免前端精度丢失
.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
// 时间类型按指定格式输出字符串
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
第二步:注册到消息转换器
src/main/java/com/itheima/reggie/config/WebMvcConfig.java:
java
/**
* 扩展mvc框架的消息转换器
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
//设置对象转换器,底层使用Jackson将Java对象转为json
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将上面的消息转换器对象追加到mvc框架的转换器集合中
converters.add(0, messageConverter);
}
为什么必须用 add(0, ...)
extendMessageConverters 传入的 converters 是 Spring MVC 已装配好的转换器列表 ,其中第一个通常是默认的 MappingJackson2HttpMessageConverter。
Spring MVC 按列表顺序遍历,用第一个 canWrite 返回 true 的转换器。如果把自己的转换器 add 到末尾,默认转换器会先命中,自定义配置完全不生效。
是(自定义转换器)
否
默认转换器
Controller 返回 R 对象
遍历 converters 列表
索引 0 的转换器
可处理该类型?
使用 JacksonObjectMapper
Long → 字符串
继续下一个
Long 输出为数字
精度丢失
效果对比
修复前:
json
{
"code": 1,
"data": {
"records": [
{ "id": 1420038345634918402, "name": "张三", "createTime": [2021,7,23,11,20,33] }
],
"total": 3
}
}
修复后:
json
{
"code": 1,
"data": {
"records": [
{ "id": "1420038345634918402", "name": "张三", "createTime": "2021-07-23 11:20:33" }
],
"total": 3
}
}
两个变化同时发生:
id从数字变为字符串 ------ 精度不再丢失createTime从数组变为yyyy-MM-dd HH:mm:ss字符串 ------ 前端可直接展示
为什么 Long 转字符串后后端仍能正常接收
前端回传 {"id":"1420038345634918402","status":0} 时,Jackson 反序列化会把字符串 "1420038345634918402" 自动转成 Long。Jackson 默认开启 ALLOW_STRING_TO_NUMBER 之类的宽松转换,所以序列化转字符串不会影响反序列化。
完整可运行代码
目录结构
text
src/main/java/com/itheima/reggie/
├── ReggieApplication.java
├── common/
│ ├── R.java
│ ├── BaseContext.java
│ ├── GlobalExceptionHandler.java
│ └── JacksonObjectMapper.java # 本篇核心:Long → String
├── config/
│ ├── MybatisPlusConfig.java
│ └── WebMvcConfig.java # 本篇核心:注册消息转换器
├── controller/
│ └── EmployeeController.java # login / logout / save / page / update
├── entity/
│ └── Employee.java
├── filter/
│ └── LoginCheckFilter.java
├── mapper/
│ └── EmployeeMapper.java
└── service/
├── EmployeeService.java
└── impl/
└── EmployeeServiceImpl.java
EmployeeController.java(update 部分)
java
package com.itheima.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
/**
* 根据id修改员工信息
* 启用/禁用员工账号 与 编辑员工信息 复用此方法
* @param request
* @param employee
* @return
*/
@PutMapping
public R<String> update(HttpServletRequest request, @RequestBody Employee employee) {
log.info(employee.toString());
long id = Thread.currentThread().getId();
log.info("线程id为:{}", id);
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}
}
WebMvcConfig.java
java
package com.itheima.reggie.config;
import com.itheima.reggie.common.JacksonObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import java.util.List;
@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
/**
* 设置静态资源映射
* @param registry
*/
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
log.info("开始进行静态资源映射...");
registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
}
/**
* 扩展mvc框架的消息转换器
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
//设置对象转换器,底层使用Jackson将Java对象转为json
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将上面的消息转换器对象追加到mvc框架的转换器集合中,索引0表示最高优先级
converters.add(0, messageConverter);
}
}
JacksonObjectMapper.java
java
package com.itheima.reggie.common;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
前端 member.js
javascript
// src/main/resources/backend/api/member.js
// 启用/禁用员工账号
function enableOrDisableEmployee(params) {
return $axios({
url: '/employee',
method: 'put',
data: { ...params }
})
}
// 编辑员工信息
function editEmployee(params) {
return $axios({
url: '/employee',
method: 'put',
data: { ...params }
})
}
前端 statusHandle 方法
javascript
// src/main/resources/backend/page/member/list.html
statusHandle(row) {
this.id = row.id
this.status = row.status
this.$confirm('确认调整该账号的状态?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
enableOrDisableEmployee({
id: row.id,
status: !row.status ? 1 : 0
}).then(res => {
if (String(res.code) === '1') {
this.$message.success('账号状态更改成功!')
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
}
功能测试
验证精度问题已修复
在浏览器控制台执行:
javascript
// 从接口响应中复制一个 id
JSON.parse('{"id":"1420038345634918402"}').id
// 输出:"1420038345634918402" ← 字符串,精度完整
测试用例
| 场景 | 操作 | 预期 |
|---|---|---|
| 禁用账号 | 用 admin 登录,点击某员工的「禁用」 |
提示成功,status 变 0,按钮变「启用」 |
| 启用账号 | 再点「启用」 | status 变 1,按钮变「禁用」 |
| 被禁账号登录 | 用被禁员工的账号密码登录 | 提示「账号已禁用」 |
非 admin 登录 |
用普通员工账号登录 | 看不到启用禁用按钮 |
查看响应 JSON |
打开分页接口的 Response |
id 是字符串,createTime 是格式化时间 |
SQL 验证
text
==> Preparing: UPDATE employee SET status=?, update_time=?, update_user=? WHERE id=?
==> Parameters: 0(Integer), 2021-07-23 11:20:33(LocalDateTime), 1(Long), 1420038345634918402(Long)
<== Updates: 1 ← 关键:影响行数必须是 1
修复前 Updates: 0,修复后 Updates: 1。
知识点延伸:JSON 数值精度的其他场景
| 场景 | 风险 | 应对 |
|---|---|---|
主键 ID |
雪花 ID 19 位,必然丢失 |
序列化为字符串(本篇方案) |
| 金额 | 0.1 + 0.2 !== 0.3 |
用 BigDecimal,序列化为字符串 |
| 时间戳 | 13 位毫秒时间戳在安全范围内 | 一般安全,但仍建议用格式化字符串 |
前端生成 ID |
前端不应生成主键 | 由后端雪花算法统一生成 |
一句话记住:超过 16 位的整数,以及涉及金额的小数,跨系统传输都建议用字符串,别让数值解析环节有机会丢精度。
API 速览
API / 注解 / 类 |
所属 | 作用 |
|---|---|---|
@PutMapping |
Spring MVC |
映射 PUT 请求 |
@RequestBody |
Spring MVC |
JSON 请求体反序列化 |
IService.updateById(T) |
MyBatis-Plus |
按主键更新,默认只更新非 null 字段 |
@TableField(updateStrategy = FieldStrategy.IGNORED) |
MyBatis-Plus |
允许将字段显式更新为 null |
extendMessageConverters |
Spring MVC |
扩展消息转换器集合 |
MappingJackson2HttpMessageConverter |
Spring MVC |
JSON 消息转换器 |
ObjectMapper |
Jackson |
对象与 JSON 互转核心类 |
SimpleModule |
Jackson |
注册自定义(反)序列化器 |
ToStringSerializer |
Jackson |
序列化为 toString() 结果(字符串) |
LocalDateTimeSerializer |
Jackson jsr310 |
按指定格式序列化 LocalDateTime |
LocalDateTimeDeserializer |
Jackson jsr310 |
按指定格式反序列化 |
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES |
Jackson |
未知字段是否报错 |
Thread.currentThread().getId() |
Java |
获取当前线程 ID,用于验证 ThreadLocal 作用域 |
$confirm |
ElementUI |
确认对话框 |
官方文档
- Jackson
ObjectMapper - Jackson
jsr310时间模块 - Spring MVC 消息转换器
- MyBatis-Plus
updateById - MDN
Number.MAX_SAFE_INTEGER
总结
整个问题的根因不在后端 SQL,也不在 MyBatis-Plus,而在前端 JavaScript 解析 JSON 数字时发生了精度丢失。处理方式是在 Jackson 序列化阶段就把 Long 转成字符串,并通过 add(0, ...) 保证自定义转换器优先生效。
排查这类问题时,如果看到「接口 200、SQL 正常、Updates: 0」,就值得回头检查请求参数里的雪花 ID 是否已经被前端改写。