Spring Boot + Vue 3 接口契约的九个裂缝:从 Long 精度到跨域凭据
裂缝都在交接处
单独看 Spring Boot,或者单独看 Vue,坑都不算多。真正难查的问题基本都发生在两边交接的地方:后端觉得"我返回的就是这个值",前端觉得"我收到的就是那个值",中间隔着一层 JSON、一层 HTTP、一层浏览器解析,任何一环有约定不一致,数据就悄悄变形了。
下面九个裂缝,都是这种"变形"。它们的共同特征是不报错——类型检查通过、接口 200、页面能打开,只是数值不对、时间偏了、字段没了。这类问题比崩溃难查得多。
裂缝一:Long 类型 ID 到了前端就不准了
这是最经典的一个,也是雪花 ID 时代必然会撞上的。
后端:
public class UserVO {
private Long id; // 1874213698257413634
}
返回的 JSON:
{ "id": 1874213698257413634 }
前端 JSON.parse 之后,id 变成 1874213698257413600。末三位没了,而且不报错。
原因在 JavaScript 的数字模型。JS 只有一种数字类型,就是 IEEE 754 双精度浮点,能精确表示的整数上限是 2^53 - 1,也就是 9007199254740991——16 位。而 64 位 Long 的正数范围能到 9.2e18,19 位。雪花 ID 恰好是 19 位,每一个都超出安全整数范围。
所以不是"有些 ID 会出问题",是"雪花 ID 全都会出问题"。表现是列表里点某一条,详情打不开;或者编辑保存后,数据跑到了别的一条记录上。
修法是在序列化阶段就把 Long 变成字符串:
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToString() {
return builder -> {
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
builder.modules(module);
};
}
Long.TYPE 那一行不能漏,否则基本类型 long 还是走数字。这两个都要注册。
前端对应的做法是所有 ID 都用 string 类型:
interface UserVO {
id: string // 不是 number
name: string
}
有两个坑要提醒。
坑一:全局改 Long 会影响统计数字。 订单数量、总金额这类字段如果也是 Long,会一起变成字符串,前端做加法就变成字符串拼接。要么把这些字段改成 Integer/BigDecimal,要么别做全局配置,只在需要的字段上单独加 @JsonSerialize(using = ToStringSerializer.class)。后者的代价是每个 VO 都要记得加,容易漏。取舍看你项目里 ID 的占比——如果 ID 遍地都是,全局配置更省心,然后在前端把"数字类字段"明确标出来。
坑二:URL 拼接时字符串是好事,比较时不是。 String(id) 拼 URL 没问题;但 if (a.id === b.id) 如果一边是字符串一边是数字就永远为 false。统一类型比统一格式更重要。
同一个坑还有个更危险的变种:金额。
后端用 BigDecimal 存金额,序列化成 JSON 数字 199.99。前端拿到 199.99,看起来没问题,但 JS 的浮点数做加法会出误差:
0.1 + 0.2 // 0.30000000000000004
199.99 * 100 // 19998.999999999996
金额乘以 100 转成分,这是最常见的操作,也是误差最容易放大的地方。对账差一分钱,往往就是这么来的。
所以金额字段也应该序列化成字符串:
@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal amount; // "199.99"
前端拿到字符串,做展示就直接用;需要计算就上 decimal.js 这类库,或者全程用"分"为单位的整数。永远不要用 JS 的浮点数做金额运算,这不是精度"可能"有问题的级别,是一定有问题。
顺带说一个相关的:前端把金额传给后端时,也别传 number。JSON.stringify(199.99) 本身没问题,但如果这个值来自一次浮点运算,传过去已经带了误差。字符串进出,是最省心的约定。
裂缝二:时间慢八小时,问题不在格式化
后端用 LocalDateTime,Jackson 默认序列化成 2026-11-04T08:27:34(没有时区后缀)。
前端拿到这个字符串,最常见的一行代码是:
new Date('2026-11-04T08:27:34')
问题就出在这一行。按 ECMAScript 规范(MDN 的表述很直白):
仅有日期的字符串(例如
"1970-01-01")被视为 UTC,而日期时间的字符串(例如"1970-01-01T12:00")被视为本地时间。
也就是说,没有时区偏移的日期时间字符串,会被当作浏览器本地时间解析。如果后端这个 08:27:34 其实是 UTC 时间(比如服务器时区是 UTC,或者容器默认 UTC),前端在中国的浏览器会理解成"北京时间 8 点 27 分",最终展示出来就差了 8 小时。
反向也一样:前端 new Date().toISOString() 得到带 Z 的 UTC 字符串,直接塞进后端的 LocalDateTime 字段,@RequestBody 反序列化可能直接失败或者丢掉时区信息。
根子在 LocalDateTime 的语义上:它表示"墙上时钟时间",本身不带时区。用它存"某一时刻"是不严谨的,只适合存"某年某月某日的某个钟点"这类与时区无关的概念(比如营业时间 09:00)。
修法按场景选:
要表示"时刻",就用带偏移的类型:
private OffsetDateTime createdAt; // 2026-11-04T08:27:34+08:00
或者用 Instant,序列化出来是带 Z 的 UTC 时间,前端解析绝对准确。
只是展示,前端统一走一层格式化工具,别在模板里直接 new Date():
// 后端约定返回带偏移的 ISO 字符串
const formatted = dayjs(isoString).format('YYYY-MM-DD HH:mm')
过渡期兼容,在字段上加明确格式,但这只解决"看起来对",不解决"语义对":
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createdAt;
timezone 参数只影响格式化输出,它不会给这个值"补上"时区概念。如果存的本来就是 UTC 值,加了 GMT+8 只是把同一个数字换了个说法,该错还是错。
判断标准一句话:这个字段需要跨时区正确吗?需要就用 Instant / OffsetDateTime,不需要(比如生日、营业时间)才用 LocalDateTime。
还有一层很多人没往上看:数据库的字段类型也会改时间。
MySQL 里 datetime 和 timestamp 的语义不一样。timestamp 会把值转成 UTC 存储,读出来时按会话时区转换;datetime 原样存原样取,不做任何转换。
这意味着,即使 Java 层用的是 LocalDateTime(不带时区),只要数据库字段是 timestamp、而 JDBC 连接串里的 serverTimezone 和服务器的时区不一致,同样的代码在不同环境就会读出不同的值。开发机上是 08:27,测试环境变成 16:27,看起来像"代码有 bug",实际是连接参数和字段类型组合出来的效果。
排查这类问题有个固定的顺序:先看数据库字段类型(datetime 还是 timestamp),再看连接串的时区参数,最后看 Java 里的类型。三处任何一处不一致,都可能产生 8 小时的偏移,而且改代码是修不好的。
比较省心的组合是:数据库统一用 datetime,Java 统一用 LocalDateTime 或明确的 OffsetDateTime,连接串显式声明时区(MySQL Connector/J 8.x 里是 connectionTimeZone,旧版本写作 serverTimezone),三处语义对齐。不要依赖"默认值应该是对的"。
裂缝三:枚举一改,前端静默失效
后端定义一个枚举:
public enum OrderStatus {
DRAFT, PAID, SHIPPED, CLOSED
}
Jackson 默认把枚举序列化成名字,所以接口返回 "PAID"。前端顺手写:
if (row.status === 'PAID') { ... }
看起来没问题,直到有一天后端把 PAID 改名成 PAIDED(或者更常见的:加了个新状态、把某个状态拆成两个)。前端这些字符串比较全部失效,没有编译错误,没有运行时异常,只是某个按钮该显示的时候不显示了。
三种应对方式,各有代价:
方式一:用数字 code。
public enum OrderStatus {
DRAFT(0), PAID(1), SHIPPED(2), CLOSED(3);
private final int code;
OrderStatus(int code) { this.code = code; }
@JsonValue
public int getCode() { return code; }
}
@JsonValue 让序列化用 code 而不是 name。好处是值稳定(数字不会因为改名而变),坏处是可读性差——日志里看到 status: 1,得回去查表。
方式二:字典接口。
后端提供一个 /api/dict/order-status,返回 [{ code: 1, label: '已支付', color: 'success' }]。前端所有展示、筛选、颜色都从字典取,不再硬编码。这是企业项目里最常见的做法,好处是运营改文案不用发版。
代价是前端多一次请求,需要处理"字典还没加载完就渲染列表"的时序。常见做法是把字典在应用启动时预加载并缓存。
方式三:代码生成。
从后端枚举直接生成前端的 TS 常量,两边同源。这是最彻底的做法,但需要一套生成工具,中小项目往往不划算。
不管选哪种,有一件事必须做:前端不要在业务代码里出现裸的枚举字符串。要么走常量文件,要么走字典,至少让"改了哪里会坏"变得可搜索。
裂缝四:Null 的字段直接消失了
这个坑不会导致报错,但会让一堆判断失灵。
如果后端配了:
spring:
jackson:
default-property-inclusion: non_null
那么值为 null 的字段根本不会出现在 JSON 里。前端拿到的是 undefined,不是 null。
差别看起来很小,实际影响不小:
// 后端返回 {},没有 remark 字段
if (obj.remark === null) { ... } // 不成立
if (obj.remark === undefined) { } // 成立
if (!obj.remark) { } // 成立,这个通常没问题
'remark' in obj // false
Object.keys(obj).length // 比你预期的少
真正出事的是这几类操作:Object.assign(target, data) 做深合并时,缺失的字段不会覆盖旧值,用户清空备注后保存,界面上还显示着旧备注;表单回填时用 Object.keys 遍历,缺失的字段压根不会被重置。
non_null 这个配置的初衷是减小响应体积,在高并发场景下确实有用。但它把"这个字段的值是空"和"这个字段不存在"两个语义混成了一个,而前端经常需要区分。
建议是:响应体不要配 non_null,让 null 明确存在。真要减体积,压缩比这个有效得多。如果必须配,前端在合并、回填这类操作里就得改用"显式赋 null"的写法,别依赖字段存在。
裂缝五:分页参数有三套叫法
这件事每次对接都要吵一遍。
- MyBatis-Plus 的
Page用current/size - Spring Data 的
Pageable用page/size(page 从 0 开始) - 前端组件库的表单通常是
page/pageSize(page 从 1 开始)
三套命名,两套起始下标,全靠一个转换层接起来。接错的后果不难想象:查第 1 页返回了第 2 页的数据,或者第一页永远少一条。
解决办法不是"小心一点",是定死一个协议,两边都只认它:
@Data
public class PageQuery {
/** 页码,从 1 开始 */
private long page = 1;
/** 每页条数,上限 200 */
private long pageSize = 10;
}
export interface PageQuery {
page?: number // 从 1 开始
pageSize?: number
}
后端在 Controller 边界把这个 DTO 翻译成 MyBatis-Plus 的 Page:
Page<Order> p = new Page<>(query.getPage(), Math.min(query.getPageSize(), 200));
启动下标、上限、默认值,全部在这一层处理掉。业务代码和前端都不用关心底层框架怎么叫。
顺带一句:pageSize 一定要有服务端上限。前端传 pageSize=100000 的时候,如果没有上限,一条 SQL 能把数据库拖垮。这个不是"防恶意",正常用户拖动每页条数选择器也可能选到一个很大的值。
裂缝六:文件下载,前端拿到的是 JSON 错误信息
后端返回文件,前端这么写:
const res = await axios.get('/api/export', { params })
少了 responseType: 'blob'。axios 默认按文本处理,二进制内容被当字符串解,写进文件就是一堆乱码,Excel 打不开。
加上就好了:
const res = await axios.get('/api/export', { params, responseType: 'blob' })
但还有个更隐蔽的情况:后端处理失败时返回的是 JSON,HTTP 状态码可能是 200 也可能 500,而前端因为设了 blob,拿到的是一个内容为 JSON 的 Blob。直接保存,用户得到一个打不开的"Excel"。
正确做法是判断一下响应的 content-type:
const blob = res.data as Blob
if (blob.type.includes('application/json')) {
const text = await blob.text()
const err = JSON.parse(text)
throw new Error(err.message || '导出失败')
}
// 才是真正的文件
后端这一侧,中文文件名也有讲究。直接写 Content-Disposition: attachment; filename=报表.xlsx,在部分浏览器和网关下会乱码。符合规范的做法是 RFC 5987 的 filename*=UTF-8'' 形式:
ContentDisposition cd = ContentDisposition.attachment()
.filename("报表.xlsx", StandardCharsets.UTF_8)
.build();
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, cd.toString());
Spring 的 ContentDisposition 会同时生成 filename 和 filename* 两个参数,前者用 ASCII 兜底,后者用 UTF-8 编码,这样新旧浏览器都能正确识别。手拼字符串很容易漏掉这个细节。
裂缝七:跨域配置里 * 和 Cookie 不能共存
本地联调时最常见的配置:
registry.addMapping("/**")
.allowedOrigins("*")
.allowCredentials(true);
启动或请求时会抛 IllegalArgumentException,信息大意是:当 allowCredentials 为 true 时,allowedOrigins 不能包含特殊值 "*",因为 "*" 没法写进 Access-Control-Allow-Origin 响应头。
这不是 Spring 的毛病,是 CORS 规范的限制:带了凭据的跨域请求,响应头里的 Access-Control-Allow-Origin 必须是具体域名,不能是通配符,否则浏览器会拒绝。
Spring 6.2 的文档写得很明确:启用凭据时,要么把 allowOrigins 设成一个或多个具体域名(不能用 *),要么改用 allowOriginPatterns。javadoc 里还补了一句,从 5.3 起这个组合就被直接拒绝了。
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:*", "https://*.example.com")
.allowCredentials(true)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.maxAge(3600);
allowedOriginPatterns 的好处是匹配到之后,响应头里写的是匹配到的那个具体 origin,不是 *,所以能跟 credentials 共存。
前端别忘了对应配置:
axios.defaults.withCredentials = true
两边都开才能带上 Cookie。只开一边的结果是"看着没报错,但登录态一直是空的"。
还有一个现象值得单独说:GET 正常,POST 报跨域。
原因在预检。不是所有跨域请求都发一次就完事,带自定义头、或者用了 PUT/DELETE、或者 Content-Type: application/json 的请求,浏览器会先发一个 OPTIONS 预检请求,等服务端允许了才发真正的请求。
预检被拦的情况有三种常见原因:Controller 没映射 OPTIONS(一般不用手写,框架会处理)、安全框架把 OPTIONS 当未授权请求拦掉了、网关没把 OPTIONS 转发到后端。
用了 Spring Security 的项目尤其容易撞上这个。Security 的过滤器链在 MVC 之前,如果没在 Security 里配置 CORS,预检请求会先被认证过滤器拦下,返回 401,浏览器就认定跨域失败。正确做法是把 CORS 配置交给 Security:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
同时把 CorsConfigurationSource 定义成一个 Bean,让 MVC 和 Security 共用同一份配置,避免两处规则不一致。
maxAge(3600) 的作用是让浏览器把这套预检结果缓存一小时,期间同类请求不再重复预检。这个值不要太短(等于每次请求多一个来回),也不要太长(改了配置后要等缓存过期才生效)。
裂缝八:HTTP 200 里藏着业务错误
很多团队的约定是:HTTP 状态码永远 200,业务是否成功看响应体里的 code。
{ "code": 50001, "msg": "余额不足", "data": null }
这个约定本身没错,问题在实现。前端的 axios 拦截器如果只判 HTTP 状态,业务错误就全被当成成功,res.data 拿到的是 { code: 50001 },然后某个组件读 res.data.list 读到 undefined,报一个与真实原因毫无关系的错。
要判两层:
axios.interceptors.response.use(
(res) => {
const body = res.data
if (body && typeof body.code === 'number' && body.code !== 0) {
// 业务错误,统一抛出
return Promise.reject(new BizError(body.code, body.msg))
}
return body.data
},
(err) => {
// HTTP 层错误
return Promise.reject(err)
}
)
这段代码有两个细节值得注意。
一是要判断 typeof body.code === 'number'。 因为文件下载、第三方回调这些接口返回的不是统一结构,不加判断会把正常响应误判成业务错误。
二是业务错误最好也映射到 HTTP 状态码。 比如参数错误返 400、未授权返 401、无权限返 403、业务规则冲突返 409。这样网关、监控、日志系统都能按状态码统计,不用解析业务码。全 200 的做法在排障时很难受——从监控上看全是成功,实际上一半请求在报错。
折中方案:状态码按语义给,响应体里再带细分的 code。两层信息,各取所需。
裂缝九:从表单过来的值,全是字符串
前端表单里的数字,v-model 绑的是 string。用户输入 123,typeof 是 string。
<input v-model="form.age" />
typeof form.age // 'string'
这时候前端 TS 类型写着 age: number,只是"你以为",运行时就是字符串。传给后端,Spring 能自动转 Integer,所以大多数时候看不出问题。但两种情况会炸。
情况一:"abc" 传过去。 类型转换失败,Spring 返回 400 MethodArgumentTypeMismatchException,前端看到的是一个没头没尾的 400。这种最好在前端就拦住——表单校验里加"必须是数字",别指望后端兜底。
情况二:空字符串。 用户清空了输入框,age 是 ""。传过去转 Integer 会失败(不是 null),同样是 400。处理方式是在提交前把空串转成 null,或者后端用 String 接收再自己解析。
v-model.number 看着像解药,但它只在 <input type="number"> 上可靠,而且空值和非法输入的时候仍然会退化成字符串。别把它当成类型保证,它只是"尽量转"。
结论很简单:表单数据在提交前必须显式做一次类型转换和校验,别依赖"框架会自动处理"。这一步放在 API 层的请求函数里,比散落在各个组件里可靠。
还有一类隐式转换在批量操作时才暴露:数组参数的序列化方式。
前端要传一组 ID:
axios.get('/api/users', { params: { ids: [1, 2, 3] } })
axios 默认用 qs 风格的序列化,实际发出去的可能是 ids[]=1&ids[]=2&ids[]=3,也可能是 ids[0]=1&ids[1]=2,取决于版本和配置。而 Spring MVC 对 @RequestParam List<Long> ids 的解析,标准支持的是 ids=1,2,3 和重复 key 的 ids=1&ids=2 两种。
对不上的结果就是:后端收到空列表,或者只收到第一个。接口不报错,只是"批量删除删了 0 条"。
修法有两个方向,选一个定死。
方向一:前端改序列化。 两种写法都能跟 Spring 的默认解析对齐,选一个:
// 写法 A:自己拼成逗号分隔的字符串
axios.get('/api/users', { params: { ids: ids.join(',') } })
// 写法 B:让 axios 发重复 key(ids=1&ids=2),不要索引形式
axios.get('/api/users', {
params: { ids },
paramsSerializer: { indexes: null }
})
写法 A 最省心,join 之后就是一个普通字符串,不依赖任何序列化配置。写法 B 依赖 axios 1.x 的 indexes 选项,版本低的时候不支持。两种都别和 ids[0]=1 这种索引形式混用——Spring 不认索引语法。
方向二:后端改用 @RequestBody 收 POST 请求。 数组放 body 里,JSON 天然支持,没有任何序列化歧义。批量操作本来就更适合 POST,语义上也比"用 GET 带一长串 ID"更合适(URL 长度还有限制)。
倾向推荐第二种。凡是"参数可能很长"的查询,都别放在 URL 里。
九个裂缝,三个共性
回头看这九个问题,它们能归成三类。
第一类是"精度和语义丢失":Long 变数字、时间丢时区、枚举丢语义。共同点是 JSON 只有一种数字类型、一种字符串类型,表达不了 Java 那边的类型丰富度。解决办法都是"在序列化层做一次显式转换",把精度敏感的值变成字符串、把时刻变成带偏移的格式。
第二类是"约定不统一":分页参数、错误码、null 的处理。这类问题没有技术难度,纯粹是团队协议问题。定一个 DTO、写进 wiki、在 code review 里守,比每次对接现商量有效。
第三类是"边界处的隐式转换":表单字符串、blob 判断、跨域凭据。这类问题的共同点是"框架帮你做了一部分,但没做全",剩下那部分得自己补。
这三类问题都不会在前端编译期暴露,也不会在后端单元测试里出现——它们只在联调时出现,而且往往是在上线前的最后一天。真正省事的做法不是记住这九个坑,而是把它们变成接口文档里的一节"数据格式约定",让下一个接手的人不用重新踩一遍。