抽奖系统项目
此博客旨在记录学习项目时遇到的各种技术问题,以便后续复盘学习,同时起到记录勉励作用。持续更新…
遇到的问题
技术细节错误
-
Mybatis字段处理器
TypeHandler:写库时自动对手机号做对称加密,读库时自动解密还原,统一把加解密收敛在持久层,便于在注册校验(如重复手机号校验)中复用,避免业务层手动处理明文。-
测试的时候遇到了登录错误的问题
测试已有管理员账户登录时,输入的正确的密码返回异常。开始以为是密码的Md5加密方式出了问题,日志捕捉后发现并没有走到验证密码这一步,且SQL语句查询正常。继续往前排查,发现校验密码前会有用户手机号AES加密解密的过程,如果手机号对不上就会返回异常。问题出现在字段处理器的手机号字段的加密方式与解密方式不匹配,数据库存的是生成的十六进制密文,但解密时把这串十六进制文本当成普通UTF-8字节数组去解,导致登录查询阶段提前报错。表现上是像密码错误,其实根本原因是
phone_number解密失败。
-
各类相似方法取舍
- StringUtils
-
用户名 / 密码校验时,hasLength与hasText区别
-
StringUtils.hasLength(str):只要不是null 且长度> 0 就是true," "(空格)会返回true -
StringUtils.hasText(str):要求有“可见文本”,即不是null、长度> 0,且至少有一个非空白字符,如果是" "(全是空格)会返回false
-
-
@Valid 和@Validated
-
在Controller层请求体对象做字段检验(
@NotNull、@NotBlank 等)时,优先使用@Valid-
@Validated更适合做分组校验,适合后续新增 / 更新不同的规则
-
- 异常通知处理
-
在校验参数时考虑是否根据不同的错误设置不同状态码 or 默认返回200
感觉接口能正常工作,应该返回200,统一结果返回时可以看到具体问题
- 如传参错误本质上是客户端请求错误,理应返回4xx,且客户端SDK、重试策略、缓存策略也常依赖状态码
反序列化问题
问题背景
在奖品创建接口中,由于需要上传奖品的属性以及奖品的图片,是一份表单格式的数据,所以用到 @RequestPart,在用到此注解中产生了一些问题。
接口为:
@PostMapping(value = "/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Long createPrize(@RequestPart("params") @Valid CreatePrizeRequest createPrizeRequest,
@RequestPart("file") MultipartFile file)
测试工具使用 Body -> form-data 传参:
-
params:text,内容为 JSON -
file:图片文件
报错
-
Required part 'params' is not present
- 阶段:Multipart part 匹配阶段
- 触发原因:后端按
@RequestPart("params")查找 part,但请求中没有被识别为该 part(key 不一致、请求头/boundary 异常、part 类型不匹配等)
由于@RequestPart 不是简单地拿字符串,它需要一个可被消息转换器识别并转换的 part
- 如果
params 是标准 JSON part(application/json),Spring 默认转换器通常可直接反序列化 - 如果
params 作为text/plain发送,默认链路可能无法稳定命中 JSON 反序列化,需要自定义 converter
踩坑点
- 项目版本及依赖差异:
- 当前项目(Spring Boot 4)实际使用
tools.jackson.* - 直接照搬
com.fasterxml.*版本的 converter 会出现类找不到或不兼容
- 过时的 API:
当时想复用现成的converter,将JSON字符串很好的解析成需要的对象属性,但由于版本差异老抽象类已经不支持使用了
改进
自定义读取型 converter
自定义MultipartJackson2HttpMessageConverter类
- 让它实现
HttpMessageConverter<Object> - 并支持
application/json、text/plain、application/octet-stream当设成 Text 传 JSON,也能被识别 - 仅支持读取(
canWrite=false),避免影响现有的响应序列化 - 使用
tools.jackson.databind.ObjectMapper主流依赖进行反序列化
实现配置类:在 MVC 转换器链中优先注册
自定义MessageConverterConfig类
- 通过
extendMessageConverters注册 - 放在索引
0,保证优先匹配@RequestPart("params")
两个类的关系
-
MultipartJackson2HttpMessageConverter:负责 “怎么转换” -
MessageConverterConfig:负责 “把谁注册进 Spring,并定义优先级”
运行逻辑
- 请求进来,命中
@RequestPart("params") - Spring 在转换器列表里找谁能读这个 part
-
MessageConverterConfig 提前注册的MultipartJackson2HttpMessageConverter被选中 - converter 调
ObjectMapper.readValue(...) 转成CreatePrizeRequest类
测试用例
- Method:
POST - URL:
/prize/create - Body:
form-data -
params:Type=Text,值:
{"name":"小车模","description":"1:64车模","price":200}
-
file:Type=File,选择图片 - 不手动改
Content-Type,让工具自动生成 multipart boundary
可复用代码
当接口需要同时接收“文件 + JSON 文本”且前端以 form-data text 方式传 JSON 时,最稳妥做法是:
- 保持
@RequestPart接口语义 - 增加只读型 multipart JSON converter
- 在 MVC 链路中前置注册
这样既保留参数校验能力(@Valid),也能兼容测试工具常见的 text/plain JSON 传法
Converter类:
/**
* 自定义 multipart 参数转换器。
* 目标是让 @RequestPart("params") 能解析 text/plain 的 JSON 文本。
*/
@Component
public class MultipartJackson2HttpMessageConverter implements HttpMessageConverter<Object> {
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = Arrays.asList(
MediaType.APPLICATION_JSON,
MediaType.TEXT_PLAIN,
MediaType.APPLICATION_OCTET_STREAM
);
private final ObjectMapper objectMapper;
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean canRead(Class<?> clazz, MediaType mediaType) {
return isSupportedMediaType(mediaType);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return SUPPORTED_MEDIA_TYPES;
}
@Override
public Object read(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
// multipart 中 params 为 text 时,按 UTF-8 文本读取后再转对象。
byte[] bodyBytes = inputMessage.getBody().readAllBytes();
String body = new String(bodyBytes, StandardCharsets.UTF_8);
if (body.isBlank()) {
throw new HttpMessageNotReadableException("请求体为空", inputMessage);
}
try {
return objectMapper.readValue(body, clazz);
}
catch (Exception e) {
throw new HttpMessageNotReadableException("params不是合法JSON", e, inputMessage);
}
}
@Override
public void write(Object o, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
// 该转换器只用于请求反序列化,不参与响应序列化。
throw new HttpMessageNotWritableException("MultipartJackson2HttpMessageConverter不支持写入");
}
private boolean isSupportedMediaType(MediaType mediaType) {
if (mediaType == null) {
return true;
}
return SUPPORTED_MEDIA_TYPES.stream().anyMatch(mediaType::isCompatibleWith);
}
}
Config类:
/**
* 注册自定义消息转换器,兼容 multipart/form-data 中 text 类型的 JSON 参数。
*/
@Configuration
public class MessageConverterConfig implements WebMvcConfigurer {
private final MultipartJackson2HttpMessageConverter multipartJackson2HttpMessageConverter;
public MessageConverterConfig(MultipartJackson2HttpMessageConverter multipartJackson2HttpMessageConverter) {
this.multipartJackson2HttpMessageConverter = multipartJackson2HttpMessageConverter;
}
/**
* 将自定义转换器放在前面,优先处理 @RequestPart 场景下的 params 文本反序列化。
*/
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, multipartJackson2HttpMessageConverter);
}
}
RedisTemplate 默认序列化器导致 JSON 缓存解析失败
问题背景
在抽奖系统项目中,活动服务提供了 getDetail 接口用于查询活动详情。为提升查询性能,在创建活动时将活动详情序列化为 JSON 并缓存到 Redis,后续查询时优先读取缓存
核心代码流程如下:
// 写入缓存:先用 JacksonUtil 手动序列化为 JSON 字符串,再存入 Redis
String json = JacksonUtil.serialize(detailDto);
redisTemplate.opsForValue().set(key, json, CACHE_TIMEOUT);
// 读取缓存:从 Redis 取出 JSON 字符串,再用 JacksonUtil 手动反序列化
String json = redisTemplate.opsForValue().get(key);
ActivityDetailDto dto = JacksonUtil.deSerialize(json, ActivityDetailDto.class);
问题现象
调用 getDetail 接口时,从 Redis 读取到了缓存数据,但反序列化阶段抛出异常:
Cannot parse JSON
Caused by: tools.jackson.core.exc.StreamReadException:
Illegal character ((CTRL-CHAR, code 0)):
only regular white space (\r, \n, \t) is allowed between tokens
通过 Redis 可视化工具查看缓存内容,发现存储的 value 前面有大量 NUL(\0)控制字符:
JSON 本身内容是正确的,但前缀的 NUL 字符导致 Jackson 解析器无法识别。
反序列化失败后,代码进入 catch 分支,执行了 redisTemplate.delete(key) 将缓存删除,造成缓存永远无法命中,每次查询都回源数据库,这非常影响性能
排查过程
初步怀疑:JacksonUtil 序列化问题?
最初怀疑是 JacksonUtil.serialize() 方法在序列化过程中引入了控制字符。但审查代码后发现:
public static String serialize(Object object) {
return JacksonUtil.tryParse(() -> OBJECT_MAPPER.writeValueAsString(object));
}
ObjectMapper.writeValueAsString() 是 Jackson 标准 API,输出的一定是合法的 JSON 字符串,不可能产生 NUL 前缀,故排除此怀疑
定位问题:RedisTemplate 默认序列化器
进一步检查发现,ActivityServiceImpl 中注入的是:
@Autowired
private RedisTemplate<String, String> redisTemplate;
但项目中没有自定义 Redis 配置类。这意味着 Spring Boot 自动配置的 RedisTemplate 使用的是默认序列化器
查阅 Spring Data Redis 源码,RedisTemplate 默认的 key 和 value 序列化器都是 JdkSerializationRedisSerializer:
// org.springframework.data.redis.core.RedisTemplate
if (this.defaultSerializer == null) {
this.defaultSerializer = new JdkSerializationRedisSerializer(...);
}
⚠️ 注意:虽然泛型声明为
RedisTemplate<String, String>,但 泛型只是编译期约束,实际运行时序列化器仍然是默认的JdkSerializationRedisSerializer,它会对任何对象(包括 String)执行 JDK 对象序列化。
还原完整问题链路
写入阶段
JacksonUtil.serialize(dto) → 纯 JSON 字符串 ✅
↓
RedisTemplate.set(key, json)
↓
JdkSerializationRedisSerializer.serialize(json) → JDK 二进制序列化 ❌
↓ (添加 NUL 开头的二进制头)
Redis 存储: [NUL NUL NUL ... ] + {"activityId":21,...}
读取阶段
RedisTemplate.get(key)
↓
JdkSerializationRedisSerializer.deserialize(bytes) → 还原出带 NUL 的字符串
↓
JacksonUtil.deSerialize(json) → 解析失败!遇到非法控制字符 ❌
所以可以总结问题根因了:JdkSerializationRedisSerializer 对已经是 JSON 字符串的 value 做了二次序列化,在 JSON 前面添加了 JDK 序列化协议的二进制头(体现为 NUL 控制字符),导致反序列化时 Jackson 无法解析
额外发现:set() 方法参数误用
redisTemplate.opsForValue().set(key, value, CACHE_TIMEOUT);
这里第三个参数 CACHE_TIMEOUT(值为 3600L)本意是设置超时时间,但 set(K, V, long) 的三参数重载中第三个参数是 offset(偏移量) ,并非过期时间。正确的超时设置需要使用四参数版本:
redisTemplate.opsForValue().set(key, value, CACHE_TIMEOUT, TimeUnit.SECONDS);
对比项目中的正确用法
有趣的是,同项目中 UserServiceImpl 的发送邮箱验证码接口sendEmailCodeInternal里,缓存邮箱验证码使用的是 StringRedisTemplate:
@Autowired
private StringRedisTemplate stringRedisTemplate;
StringRedisTemplate 是 RedisTemplate<String, String> 的子类,构造函数中已将序列化器设置为 StringRedisSerializer,所以邮箱验证码的存取从未出现过类似问题。
解决方案
方案思路
既然问题出在"手动 Jackson 序列化 + 默认 JDK 序列化器"的冲突,我选择让 RedisTemplate 自身接管 JSON 序列化,彻底移除手动转换步骤。
新增 Redis 配置类
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key 使用 String 序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// value 使用 Jackson JSON 序列化,自动完成对象 ↔ JSON 转换
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
GenericJackson2JsonRedisSerializer 会在 JSON 中自动写入 @class 类型信息,读取时可以自动还原为正确的 Java 类型
更改后的Redis存入的数据如图,已经能正常序列化:
简化业务代码
写入缓存 —— 直接存对象:
- redisTemplate.opsForValue().set(key, JacksonUtil.serialize(detailDto), CACHE_TIMEOUT);
+ redisTemplate.opsForValue().set(key, detailDto, CACHE_TIMEOUT, TimeUnit.SECONDS);
读取缓存 —— 直接取对象:
- String json = redisTemplate.opsForValue().get(key);
- ActivityDetailDto dto = JacksonUtil.deSerialize(json, ActivityDetailDto.class);
+ Object cached = redisTemplate.opsForValue().get(key);
+ if (cached instanceof ActivityDetailDto dto) {
+ return dto;
+ }
完全移除了 JacksonUtil 在缓存场景的使用,以及之前为兼容脏数据写的大段 fallback 解析逻辑。
核心收获
RedisTemplate vs StringRedisTemplate
| 特性 | RedisTemplate |
StringRedisTemplate |
|---|---|---|
| 默认序列化器 | JdkSerializationRedisSerializer |
StringRedisSerializer |
| 存入 Redis 的格式 | JDK 二进制(不可读) | 纯文本(可读) |
| 取出后类型 | Object(需强转) | String |
| 适用场景 | 需自定义序列化器后使用 | 简单字符串 KV |
📌 教训:使用
RedisTemplate 时,必须显式配置序列化器,否则默认的 JDK 序列化会与手动 JSON 序列化产生冲突。
常见的 Redis 序列化器选择
| 序列化器 | 存储格式 | 类型信息 | 适用场景 |
|---|---|---|---|
JdkSerializationRedisSerializer |
二进制 | 内嵌 | ❌ 不推荐,可读性差且有安全风险 |
StringRedisSerializer |
纯字符串 | 无 | 简单字符串、需手动 JSON 转换 |
Jackson2JsonRedisSerializer |
JSON | 无 | 明确类型的场景 |
GenericJackson2JsonRedisSerializer |
JSON + @class | 有 | 通用 JSON 对象缓存 ✅ |
set() 方法重载辨析
// ❌ 错误:第三个参数是 offset(偏移量),不是超时时间
set(K key, V value, long offset)
// ✅ 正确:显式指定时间单位的过期时间
set(K key, V value, long timeout, TimeUnit unit)
// ✅ 正确:使用 Duration 的过期时间
set(K key, V value, Duration timeout)
总结
这个问题的表面现象是"JSON 解析失败",但深层原因是对 RedisTemplate 默认行为的认知不足。整个排查链条为:
JSON 解析报控制字符异常
→ Redis 中缓存值有 NUL 前缀
→ JdkSerializationRedisSerializer 对 JSON 字符串二次序列化
→ 未自定义 RedisTemplate 的序列化器配置
最终通过添加 RedisConfig 配置类,使用 GenericJackson2JsonRedisSerializer 让 RedisTemplate 自身处理 JSON 序列化,从根本上解决了问题,同时也大幅简化了业务代码。