Skip to content
第 55 / 250 章后端⏱ 10 分钟阅读

第 55 章:自动填充与枚举

学习目标

  • 实现创建/更新时间与操作人自动填充
  • 掌握枚举与数据库值的映射
  • 学会 JSON 字段与自定义类型处理器

一、自动填充:告别重复 setter

java
// ❌ 每次插入都手动设置四个字段
user.setCreateTime(LocalDateTime.now());
user.setCreateBy(currentUserId);
user.setUpdateTime(LocalDateTime.now());
user.setUpdateBy(currentUserId);
userMapper.insert(user);

第 1 步:实体标注

java
@Data
public class BaseEntity {

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT)
    private Long createBy;

    @TableField(fill = FieldFill.INSERT_UPDATE)     // ① 插入和更新都填
    private LocalDateTime updateTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateBy;

    @TableLogic
    @TableField(select = false)                      // ② 查询时不返回这个字段
    private Integer deleted;
}

@Data
@EqualsAndHashCode(callSuper = true)                 // ③ Lombok:继承时必须加
@TableName("sys_user")
public class User extends BaseEntity {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private String username;
}

第 2 步:实现填充处理器

java
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        LocalDateTime now = LocalDateTime.now();

        // ① strictInsertFill:字段有值时不覆盖(推荐,允许手动指定)
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);

        Long userId = getCurrentUserId();
        if (userId != null) {
            this.strictInsertFill(metaObject, "createBy", Long.class, userId);
            this.strictInsertFill(metaObject, "updateBy", Long.class, userId);
        }
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        // ② 更新时间强制覆盖,不允许手动指定
        this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);

        Long userId = getCurrentUserId();
        if (userId != null) {
            this.setFieldValByName("updateBy", userId, metaObject);
        }
    }

    /** ③ 从安全上下文取当前用户,取不到就返回 null(定时任务、系统初始化场景) */
    private Long getCurrentUserId() {
        try {
            return SecurityUtils.getCurrentUserId();
        } catch (Exception e) {
            return null;
        }
    }
}
方法行为
strictInsertFill字段为 null 时才填充
setFieldValByName强制覆盖

③ 为什么要 try-catch? 定时任务、消息消费、系统启动时的数据初始化都没有登录用户。不处理会抛异常导致整个插入失败。

⚠️ 自动填充不生效的场景

java
// ① UpdateWrapper 不带实体时,updateFill 不触发
userService.lambdaUpdate()
        .eq(User::getId, 1L)
        .set(User::getStatus, 0)
        .update();                       // ❌ updateTime 不会自动更新

// ✅ 手动加上
userService.lambdaUpdate()
        .eq(User::getId, 1L)
        .set(User::getStatus, 0)
        .set(User::getUpdateTime, LocalDateTime.now())
        .update();

// ② 自定义 XML 的 SQL 完全不走填充器
// ③ 数据库层面的默认值(DEFAULT CURRENT_TIMESTAMP)和这个是两套机制

兜底方案:数据库字段也设默认值,双保险。

sql
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

二、枚举映射

问题:数据库存数字,代码里想用枚举

java
// ❌ 到处是魔法值
if (user.getStatus() == 1) { }
user.setStatus(0);

方案 1:@EnumValue(推荐)

java
@Getter
public enum UserStatus {

    DISABLED(0, "禁用"),
    NORMAL(1, "正常"),
    LOCKED(2, "锁定");

    @EnumValue                  // ① 标记:这个字段的值存入数据库
    private final int code;

    @JsonValue                  // ② 标记:序列化给前端时用这个字段
    private final String desc;

    UserStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    /** ③ 反序列化:前端传值时如何转成枚举 */
    @JsonCreator
    public static UserStatus of(int code) {
        return Arrays.stream(values())
                .filter(e -> e.code == code)
                .findFirst()
                .orElseThrow(() -> new BusinessException(
                        ErrorCode.PARAM_INVALID, "非法的状态值: " + code));
    }
}
yaml
mybatis-plus:
  type-enums-package: com.taskflow.**.enums     # ① 扫描枚举包
java
@Data
public class User {
    private UserStatus status;      // ② 直接用枚举类型
}

// 使用
user.setStatus(UserStatus.NORMAL);                 // 存入数据库的是 1
if (user.getStatus() == UserStatus.NORMAL) { }     // 类型安全,IDE 有提示

JSON 返回效果

json
{ "status": "正常" }     // 因为 @JsonValue 标在 desc 上

设计权衡:返回 "正常" 前端展示方便,但前端做逻辑判断时用中文字符串很脆弱(改文案就崩)。 更好的做法:返回对象 {"status": 1, "statusName": "正常"}——code 给逻辑用,name 给展示用。

java
// 实现方式:VO 里放两个字段
@Data
public class UserVO {
    private Integer status;
    private String statusName;
}

方案 2:实现 IEnum 接口

java
public enum UserStatus implements IEnum<Integer> {
    DISABLED(0, "禁用"),
    NORMAL(1, "正常");

    private final int code;
    private final String desc;

    @Override
    public Integer getValue() {     // 实现这个方法即可
        return code;
    }
}

枚举校验注解

java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
public @interface EnumValue {
    Class<? extends Enum<?>> enumClass();
    String method() default "getCode";
    String message() default "参数值不在允许范围内";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {

    private Class<? extends Enum<?>> enumClass;
    private String method;

    @Override
    public void initialize(EnumValue anno) {
        this.enumClass = anno.enumClass();
        this.method = anno.method();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext ctx) {
        if (value == null) return true;

        Object[] constants = enumClass.getEnumConstants();
        if (constants == null) return false;

        try {
            Method m = enumClass.getMethod(method);
            for (Object constant : constants) {
                if (Objects.equals(m.invoke(constant), value)) return true;
            }
        } catch (Exception e) {
            return false;
        }
        return false;
    }
}
java
@EnumValue(enumClass = UserStatus.class, message = "状态值非法")
private Integer status;

三、JSON 字段映射

场景:一个字段存 JSON(如用户扩展属性、商品规格)。

sql
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY,
    ext_info JSON,                         -- MySQL 5.7+ 支持 JSON 类型
    tags VARCHAR(500)                      -- 或者用字符串存
);
java
@Data
@TableName(value = "sys_user", autoResultMap = true)   // ① 必须开启 autoResultMap
public class User {

    @TableField(typeHandler = JacksonTypeHandler.class)  // ② 指定处理器
    private UserExtInfo extInfo;                         // 对象自动转 JSON

    @TableField(typeHandler = JacksonTypeHandler.class)
    private List<String> tags;                           // 列表也可以
}

@Data
public class UserExtInfo {
    private String wechat;
    private String github;
    private Map<String, Object> custom;
}

autoResultMap = true 不能忘。不加的话查询时 TypeHandler 不生效,只有插入生效——表现为「存进去了但查出来是 null」。

JSON 字段的取舍

优点缺点
灵活,加字段不用改表结构无法建索引(MySQL 8 可用虚拟列变通)
减少表数量查询条件无法用 JSON 内部字段
适合稀疏属性数据一致性靠应用层保证

使用原则:只用于不需要查询和统计的辅助信息。核心业务字段必须建成独立列。

四、自定义 TypeHandler

场景:数据库存逗号分隔字符串 "1,2,3",代码里想用 List<Long>

java
@MappedTypes(List.class)
public class LongListTypeHandler extends BaseTypeHandler<List<Long>> {

    private static final String DELIMITER = ",";

    /** ① Java → 数据库 */
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    List<Long> parameter, JdbcType jdbcType)
            throws SQLException {
        ps.setString(i, parameter.stream()
                .map(String::valueOf)
                .collect(Collectors.joining(DELIMITER)));
    }

    /** ② 数据库 → Java */
    @Override
    public List<Long> getNullableResult(ResultSet rs, String columnName)
            throws SQLException {
        return parse(rs.getString(columnName));
    }

    @Override
    public List<Long> getNullableResult(ResultSet rs, int columnIndex)
            throws SQLException {
        return parse(rs.getString(columnIndex));
    }

    @Override
    public List<Long> getNullableResult(CallableStatement cs, int columnIndex)
            throws SQLException {
        return parse(cs.getString(columnIndex));
    }

    private List<Long> parse(String value) {
        if (!StringUtils.hasText(value)) return new ArrayList<>();
        return Arrays.stream(value.split(DELIMITER))
                .filter(StringUtils::hasText)
                .map(Long::valueOf)
                .toList();
    }
}
java
@TableName(value = "sys_role", autoResultMap = true)
public class Role {
    @TableField(typeHandler = LongListTypeHandler.class)
    private List<Long> menuIds;
}

⚠️ 逗号分隔字段是反范式设计。它让「查询拥有某菜单的所有角色」变成 LIKE '%,5,%' 的全表扫描,而且边界匹配容易出错(5 会匹配到 15)。多对多关系应该建中间表。这个 TypeHandler 主要用于对接遗留系统。

五、字段加密(TypeHandler 实战)

场景:手机号、身份证在数据库中加密存储。

java
public class EncryptTypeHandler extends BaseTypeHandler<String> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    String parameter, JdbcType jdbcType)
            throws SQLException {
        ps.setString(i, AesUtils.encrypt(parameter));       // ① 写入前加密
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName)
            throws SQLException {
        return AesUtils.decrypt(rs.getString(columnName));  // ② 读取后解密
    }
    // 其他重载方法同理
}
java
@TableField(typeHandler = EncryptTypeHandler.class)
private String idCard;

⚠️ 加密字段无法做精确查询和排序。如果需要按手机号查询,方案是:额外存一个 phone_hash 字段(SHA256(phone + salt)),查询时比对 hash。

六、本章小结

要点关键
自动填充@TableField(fill=...) + MetaObjectHandler
填充失效UpdateWrapper、自定义 XML 不触发
兜底数据库字段也设 DEFAULT
枚举@EnumValue 标数据库值,@JsonValue 标前端值
枚举返回推荐返回 code + name 两个字段
JSON 字段必须 autoResultMap = true
JSON 使用原则只存不需要查询的辅助信息
逗号分隔反范式,优先建中间表
字段加密需查询时额外存 hash 列

动手练习

练习 1:基础题

实现 BaseEntity + MetaObjectHandler,让所有表的四个审计字段自动填充。验证 insert 和 update 都生效。

练习 2:进阶题

定义订单状态枚举(待支付/已支付/已发货/已完成/已取消),实现 @EnumValue 校验注解,并让接口返回 {"status":1,"statusName":"已支付"} 格式。


下一章第 56 章:多租户与动态表名

本站基于 VitePress 构建 · 由 Codebook 团队维护