Spring Boot + Vue 3 接口契约的九个裂缝:从 Long 精度到跨域凭据

作者:忆笙智云官方 | 发布时间:2026-11-04 00:26 | 更新时间:2026-11-04 00:26

裂缝都在交接处

单独看 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"的写法,别依赖字段存在。

裂缝五:分页参数有三套叫法

这件事每次对接都要吵一遍。

三套命名,两套起始下标,全靠一个转换层接起来。接错的后果不难想象:查第 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 判断、跨域凭据。这类问题的共同点是"框架帮你做了一部分,但没做全",剩下那部分得自己补。

这三类问题都不会在前端编译期暴露,也不会在后端单元测试里出现——它们只在联调时出现,而且往往是在上线前的最后一天。真正省事的做法不是记住这九个坑,而是把它们变成接口文档里的一节"数据格式约定",让下一个接手的人不用重新踩一遍。