Commit d8736010 by DaiJiezhang

fix: 让可清空字段的 null 真正写进 UPDATE 语句

MyBatis-Plus 的 updateById 默认用 FieldStrategy.NOT_NULL,实体里值为 null 的
字段会被整个从 SET 子句里剔除。表现极具欺骗性:接口返回成功、update_time 也
更新了,唯独那一列的旧值原封不动,刷新后又冒出来。

已确认受影响的操作:
- 设备资产「移除图片」保存后列表仍显示旧图
- 设备资产、手机号码管理「清空使用人 / 运营商 / ICCID / 实名人 / 管理模式 /
  关联设备」保存后均不生效

给这些业务上允许清空的字段声明 updateStrategy = ALWAYS。企微、微信、抖音的
device_id 目前还没有编辑接口,一并声明是提前立规矩,避免补编辑功能时重蹈覆辙。

没有改全局 update-strategy 配置:那会让所有模块的 update 都把 null 写进库,
可能在别处造成数据丢失。放开 ALWAYS 的前提是所有 updateById 调用点都走
「先查全量实体再改字段」,已逐个核对确认。

新增 NullableColumnUpdateTest 直接检查 MyBatis-Plus 生成的 SET 子句,而不是
mock 掉 Mapper——原来的 Service 测试正是因为 mock 了 Mapper、SQL 从未被验证,
才让这个缺陷漏了出去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent fc0e6702
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
...@@ -16,11 +17,17 @@ import lombok.EqualsAndHashCode; ...@@ -16,11 +17,17 @@ import lombok.EqualsAndHashCode;
@TableName("as_asset_device") @TableName("as_asset_device")
public class AssetDeviceEntity extends AssetBaseEntity { public class AssetDeviceEntity extends AssetBaseEntity {
private String deviceName; private String deviceName;
/** 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。 */ /**
@TableField("image_attachment_1") * 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。
* updateStrategy 必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"移除图片"设置的 null 永远写不进数据库——接口返回成功、update_time 也变了,唯独图片引用还在,列表刷新后图片照旧显示。
*/
@TableField(value = "image_attachment_1", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment1; private String imageAttachment1;
@TableField("image_attachment_2") @TableField(value = "image_attachment_2", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment2; private String imageAttachment2;
/** 同上:使用人允许清空,不写 ALWAYS 的话清空操作同样会被默默丢弃。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long userPersonId; private Long userPersonId;
private String userUsageStatus; private String userUsageStatus;
private String assetRelationStatus; private String assetRelationStatus;
......
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -18,6 +20,8 @@ public class DouyinAccountEntity extends AssetBaseEntity { ...@@ -18,6 +20,8 @@ public class DouyinAccountEntity extends AssetBaseEntity {
private String realNameOwner; private String realNameOwner;
private Long companyProfileId; private Long companyProfileId;
private Long phoneAssetId; private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -15,11 +17,22 @@ import lombok.EqualsAndHashCode; ...@@ -15,11 +17,22 @@ import lombok.EqualsAndHashCode;
@TableName("as_phone_asset") @TableName("as_phone_asset")
public class PhoneAssetEntity extends AssetBaseEntity { public class PhoneAssetEntity extends AssetBaseEntity {
private String phoneNumber; private String phoneNumber;
/**
* 以下五个字段都是编辑弹窗里允许清空的(运营商、管理模式、关联设备是 clearable 下拉,ICCID 和实名人是可删空的输入框),
* 所以必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"清空后保存"接口返回成功、update_time 也变了,唯独这一列的旧值原封不动,刷新后又冒出来。
* 这里能安全放开的前提是 PhoneAssetService.update 走的是"先查全量实体再改字段",没有只 set 部分字段的局部更新。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String cardType; private String cardType;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String iccid; private String iccid;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String realNameOwner; private String realNameOwner;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String managementType; private String managementType;
private String disposalStatus; private String disposalStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private String numberType; private String numberType;
private String sourceAssetType; private String sourceAssetType;
......
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -17,6 +19,8 @@ public class WechatAccountEntity extends AssetBaseEntity { ...@@ -17,6 +19,8 @@ public class WechatAccountEntity extends AssetBaseEntity {
private String wechatId; private String wechatId;
private String realNameOwner; private String realNameOwner;
private Long phoneAssetId; private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -23,6 +25,11 @@ public class WecomAccountEntity extends AssetBaseEntity { ...@@ -23,6 +25,11 @@ public class WecomAccountEntity extends AssetBaseEntity {
private String realNameOwner; private String realNameOwner;
private String realNameOwnerStatus; private String realNameOwnerStatus;
private String gender; private String gender;
/**
* 这条目前还没有编辑接口,清空路径走不到,这里是提前立规矩:一旦补上编辑功能,
* 默认的 NOT_NULL 策略会让"取消关联设备"静默失效(设备资产的移除图片就是这么坏的)。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.entity;
import static org.junit.jupiter.api.Assertions.assertFalse;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* 文件用途(白话):验证"业务上允许清空"的列真的会出现在 UPDATE 语句里。
*
* 为什么要有这个测试:MyBatis-Plus 的 updateById 默认用 FieldStrategy.NOT_NULL,
* 实体里值为 null 的字段会被整个从 SET 子句里剔掉。表现极具欺骗性——接口返回成功、
* update_time 也更新了,唯独那一列的旧值纹丝不动,刷新后又冒出来。
*
* 这个测试直接检查生成的 SQL 片段,而不是 mock 掉 Mapper:
* 设备资产"移除图片保存后列表还显示旧图"就是因为原来的 Service 测试 mock 了 Mapper、
* SQL 从头到尾没被验证过,才让 bug 一路漏到线上。
*/
class NullableColumnUpdateTest {
/** 代码作用(白话):逐个确认可清空列不会被 null 判断包裹。关联文件:各资产 Entity、对应 Service 的 update 方法。关联逻辑(调用链/数据流):表单清空 -> entity 字段置 null -> updateById -> SET 子句必须包含该列。 */
@DisplayName("可清空的列必须留在 UPDATE 的 SET 子句里")
@ParameterizedTest(name = "{0}.{1}")
@CsvSource({
// 设备资产:移除图片、清空使用人
"AssetDeviceEntity, image_attachment_1",
"AssetDeviceEntity, image_attachment_2",
"AssetDeviceEntity, user_person_id",
// 手机号码管理:运营商、ICCID、实名人、管理模式、关联设备都能在弹窗里清空
"PhoneAssetEntity, card_type",
"PhoneAssetEntity, iccid",
"PhoneAssetEntity, real_name_owner",
"PhoneAssetEntity, management_type",
"PhoneAssetEntity, device_id",
// 这三类目前还没有编辑接口,先把规矩立住,补编辑功能时不会重蹈覆辙
"WecomAccountEntity, device_id",
"WechatAccountEntity, device_id",
"DouyinAccountEntity, device_id"
})
void keepsNullableColumnsInsideTheUpdateStatement(String entityName, String column) throws Exception {
Class<?> entity = Class.forName(getClass().getPackageName() + "." + entityName.trim());
TableInfo table = TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entity);
String assignment = table.getAllSqlSet(false, "et.").lines().filter(line -> line.contains(column.trim() + "=#{et.")).findFirst()
.orElseThrow(() -> new AssertionError(entityName + " 的 " + column + " 没有出现在 UPDATE 的 SET 子句里"));
// NOT_NULL 策略会把赋值包进 <if test="et['xxx'] != null">,一旦出现就说明置空又会被悄悄丢弃。
// 按整行判断而不是匹配具体表达式:MyBatis-Plus 换个写法(et.x / et['x'])断言也不会失效。
assertFalse(assignment.contains("<if"), entityName + " 的 " + column + " 被 null 判断包裹,置空将写不进数据库");
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment