最近做前后端分离项目遇到一个很迷惑的 bug,在这里记录一下。
为了防止 XSS 和特殊字符存储问题,数据库里直接存了 HTML 转义后的实体,像"、<这类。很多同事的做法是在 Java 业务代码里循环列表,拿到实体之后直接对字段做 HTML 反转义。
但这个写法有坑:接口返回状态码明明是 200,前端却一直报数据加载失败、JSON 解析错误。一旦把后端的反转义代码注释掉,接口马上恢复正常。
原因其实不难理解。 数据库里原始保存的字符串是"10"钢管,后端手动反转义之后,就变成了"10"钢管"。字符串里面出现了裸双引号。如果这个时候直接 set 进实体对象,等到 Jackson 序列化生成 JSON 的时候,引号会直接打乱 JSON 结构,最终生成非法的 JSON 文本,类似下面这样:
{
"spec": "10"钢管""
}
而没做解码的"只是普通文本字符,不会干扰 JSON 语法,所以可以正常解析。
千万不要写这种代码,生产环境踩坑概率极高
// 错误写法!
for (Entity entity : list) {
entity.setSpec(HtmlUtil.smartUnescapeHtml(entity.getSpec()));
}
提前修改实体字段,等于在序列化之前就把裸引号、特殊符号塞到对象里,最后输出的 JSON 报文直接损坏。
方案一:SpringBoot 后端处理,适合多端项目
思路很简单:不去改动实体里面原始的值,把 HTML 解码放到 Jackson 序列化阶段。序列化输出的时候再处理,Jackson 会自动帮我们转义 JSON 敏感字符,保证报文合法,Vue、小程序、APP 都能拿到已经解码好的文本。
先写 HTML 反转义工具类:
import org.apache.commons.lang3.StringEscapeUtils;
public class HtmlUnEscapeUtil {
/**
* 智能HTML反转义
* 兼容项目u0026特殊转义残留,还原标准HTML实体字符
*/
public static String smartUnescapeHtml(String s) {
if (s == null) return null;
// 兼容项目特有 u0026 转义残留
s = s.replace("u0026", "&");
// 标准HTML实体反转义
return StringEscapeUtils.unescapeHtml(s);
}
}
自定义 Jackson 序列化器:
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
public class HtmlUnescapeSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
// 解码后由Jackson自动完成JSON特殊字符转义,保证报文合法
gen.writeString(HtmlUnEscapeUtil.smartUnescapeHtml(value));
}
}
DTO 上通过注解指定哪些字段需要自动解码,按需启用:
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
public class GoodsDTO {
// 规格字段自动HTML反转义
@JsonSerialize(using = HtmlUnescapeSerializer.class)
private String spec;
// 描述字段自动HTML反转义
@JsonSerialize(using = HtmlUnescapeSerializer.class)
private String description;
// getter、setter
}
这种方式好处很明显:实体原始数据不会被污染,JSON 报文始终合法;所有客户端拿到的都是解码完成的文本。缺点就是需要新增序列化器,DTO 需要加注解,少量改造后端代码。
方案二:Vue3 前端渲染解码,后端完全不用改
如果只需要后台页面展示,不想动后端代码,就可以选择在前端渲染的时候再解码。后端原样返回带 HTML 实体的原始字符串,在 Vue 渲染阶段处理,从根源避开后端 JSON 损坏的风险。
前端工具函数,逻辑和后端保持一致:
// utils/html.js 全局工具方法
export function smartUnescapeHtml(s) {
if (!s) return s;
// 兼容后端u0026特殊转义字符
let str = s.replace(/u0026/g, "&");
const dom = document.createElement("div");
dom.innerHTML = str;
// 采用innerText解析,自动规避XSS风险,只输出纯文本
return dom.innerText;
}
Vue3 + Element Plus 表格示例,直接在模板里调用:
<template>
<el-table :data="tableData" border>
<el-table-column label="规格" prop="spec">
<template #default="{ row }">
{{ smartUnescapeHtml(row.spec) }}
</template>
</el-table-column>
<el-table-column label="商品描述" prop="description">
<template #default="{ row }">
{{ smartUnescapeHtml(row.description) }}
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { ref } from "vue";
import { smartUnescapeHtml } from "@/utils/html";
const tableData = ref([]);
</script>
如果文本太长需要截断,顺序一定要注意:先解码,再截取。如果反过来,HTML 实体被截断一半,就会出现乱码。
// 超长文本格式化展示
function getShowText(val, len = 10) {
// 优先解码,再做长度截取
const str = smartUnescapeHtml(val);
if (!str) return "";
return str.length > len ? str.slice(0, len) + "..." : str;
}
- 多端共用接口(Vue、小程序、APP),需要统一展示效果:选 SpringBoot 自定义序列化器。全局统一处理,前端不用关心解码逻辑。
- 仅仅后台管理页面展示,其他端保留原始转义文本:直接 Vue 前端渲染解码,改造成本最低。
开发避坑小结
- 不要在循环实体的时候 set 解码后的字符串,这是 JSON 报错最常见的原因;
- HTTP 返回 200 不代表数据没问题,只是网络请求成功,JSON 语法错误照样解析失败;
- 后端解码要放在序列化阶段,业务层不要修改原始实体字段;
- 前端文本处理顺序:先解码,再做截取、格式化、渲染;
- 前端用 innerText 获取文本,不会执行脚本,不用担心 XSS。
常见疑问
Q:接口返回 HTTP 200,为什么前端还是解析失败?
A:200 只是网络层面请求成功。后端反转义生成的裸引号会破坏 JSON 语法,前端 JS 解析 JSON 直接抛出异常,页面表现就是加载失败。
Q:生产环境优先选哪套方案?A:多端共用接口优先后端序列化方式;只有 Vue 后台页面使用,前端解码更省事。
Q:前端这种解码会不会有 XSS 风险?A:这里用 innerText,只会读取纯文本,不会解析执行 html 脚本,生产环境安全可用。