4. 后端实体类与DTO设计:使用JPA/Hibernate定义实体,DTO与VO分层设计
好,咱们直接进入正题。营销活动管理模块,说白了就是一堆活动规则、参与记录、奖励发放这些东西在数据库里怎么存、怎么传。我见过太多项目,实体类和DTO混在一起用,最后改一个字段要改七八个地方,那叫一个酸爽。
今天我就把我在几个电商促销项目里踩过的坑、总结的经验,一次性给你讲透。
4.1 实体类设计:JPA/Hibernate 的正确姿势
先说说实体类。你用 JPA 还是 MyBatis?我个人习惯用 JPA + Hibernate,因为对于营销活动这种关联关系复杂的场景,ORM 的懒加载和级联操作能省不少事。
但注意,别滥用!
我曾经在一个项目中,把活动、规则、奖品、用户参与记录全部用 @OneToMany 级联起来。结果查询一个活动,Hibernate 给我拉了 6 张表的数据,接口响应时间直接飙到 3 秒。后来全改成手动查询,才降到 200 毫秒。
来看一个典型的营销活动实体类设计:
@Entity
@Table(name = "marketing_activity")
public class MarketingActivity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String activityName; // 活动名称
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ActivityType activityType; // 活动类型:满减、折扣、赠品
@Column(nullable = false)
private LocalDateTime startTime; // 开始时间
@Column(nullable = false)
private LocalDateTime endTime; // 结束时间
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ActivityStatus status; // 状态:草稿、进行中、已结束
@Column(columnDefinition = "TEXT")
private String rules; // JSON 格式的活动规则
@Column(nullable = false)
private Integer maxParticipantCount; // 最大参与人数
@Column(nullable = false)
private Integer currentParticipantCount = 0; // 当前参与人数
@Version
private Integer version; // 乐观锁,防止并发超卖
@CreatedDate
private LocalDateTime createdTime;
@LastModifiedDate
private LocalDateTime updatedTime;
// getter/setter 省略
}
这里有几个关键点我要强调一下:
- 用 @Version 做乐观锁:营销活动经常有并发问题,比如 100 个人同时参与,库存只有 50 份。没有乐观锁,你就等着超卖吧。
- 枚举用字符串存:@Enumerated(EnumType.STRING) 而不是 ORDINAL。为什么?因为枚举顺序一变,数据库里的数字就全乱了。我吃过这个亏。
- 规则字段用 TEXT 存 JSON:活动规则千变万化,你不可能为每种规则建一张表。存 JSON 字符串,灵活又简单。
4.2 DTO 与 VO 分层设计:别再偷懒了
很多新手喜欢直接把实体类返回给前端。你想想看,实体类里可能包含密码、内部状态、版本号这些敏感信息,直接暴露出去不是找事吗?
我建议的分层是这样的:
| 层级 | 用途 | 典型场景 |
|---|---|---|
| Entity | 数据库映射,包含所有字段 | ORM 操作、持久化 |
| DTO | 数据传输,按需裁剪字段 | Service 层返回给 Controller |
| VO | 视图对象,前端展示专用 | Controller 返回给前端 |
说白了,DTO 是给后端内部用的,VO 是给前端看的。举个例子:
// DTO:后端内部传输用
public class ActivityDTO {
private Long id;
private String activityName;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String rules; // 包含完整规则 JSON
private Integer version; // 乐观锁版本号,后端校验用
}
// VO:返回给前端
public class ActivityVO {
private Long id;
private String activityName;
private String startTime; // 格式化后的字符串
private String endTime;
private String statusDesc; // 状态的中文描述
private String ruleSummary; // 规则摘要,前端展示用
}
你看,VO 里我把时间格式化了,把状态转成了中文,还加了个规则摘要。这些在 DTO 里都不需要。
我的一个小技巧:用 MapStruct 做对象转换,比手写 BeanUtils.copyProperties 快 10 倍,而且类型安全。我在项目中一直用这个。
4.3 避坑指南:我踩过的那些坑
嗯,这里我要多说几句。实体类和 DTO 设计看似简单,但坑不少。
- 别在实体类里写业务逻辑:实体类只负责数据映射,业务逻辑放到 Service 层。我曾经见过有人在实体类里写满减计算,结果一改需求,所有地方都要改。
- DTO 不要直接引用实体类:DTO 里不要出现 @Entity 注解的类作为字段。否则序列化时会出问题,而且破坏了分层。
- VO 里不要有敏感字段:比如用户手机号、内部状态码。前端不需要这些。
核心原则:每一层只关心自己该关心的事。实体类关心数据库,DTO 关心传输,VO 关心展示。各司其职,才能好维护。
4.4 实战:一个完整的转换流程
最后,我给你看一个完整的 Service 方法,你就明白怎么用了:
@Service
public class ActivityService {
@Autowired
private ActivityRepository activityRepository;
@Autowired
private ActivityMapper activityMapper; // MapStruct 生成的转换器
public ActivityVO getActivityDetail(Long activityId) {
// 1. 查询实体
MarketingActivity entity = activityRepository.findById(activityId)
.orElseThrow(() -> new BusinessException("活动不存在"));
// 2. 转成 DTO(可能包含一些计算逻辑)
ActivityDTO dto = activityMapper.toDTO(entity);
dto.setRemainingParticipantCount(
dto.getMaxParticipantCount() - dto.getCurrentParticipantCount()
);
// 3. 转成 VO(格式化展示数据)
ActivityVO vo = activityMapper.toVO(dto);
vo.setStatusDesc(ActivityStatusEnum.getDescByCode(dto.getStatus()));
vo.setStartTime(dto.getStartTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
return vo;
}
}
你看,每一步都很清晰。实体 -> DTO -> VO,各层职责明确。改前端展示?改 VO 就行。改数据库字段?改实体类就行。互不影响。
好了,这一章就到这里。下一章我会讲营销活动的规则引擎设计,那才是真正的硬核内容。到时候见。