feat(mobile): 移动端应用配置加密存储与敏感字段脱敏

完善平台级移动端应用配置: app_config/notify_config 改 DataEncryptTypeHandler 加密入库, 返回脱敏, 保存时敏感键空值保留原值; 补建 pay_platform_mobile_app 表。
This commit is contained in:
DaxPay Dev
2026-07-09 21:44:54 +08:00
parent d2c9197f00
commit d5af60142f
4 changed files with 145 additions and 13 deletions

View File

@@ -158,3 +158,46 @@ CREATE INDEX IF NOT EXISTS idx_pay_platform_wechat_message_record_send_time ON p
-- rename iam_social_config -> iam_social_login_config (avoid naming clash with upcoming third-party platform management)
ALTER TABLE IF EXISTS iam_social_config RENAME TO iam_social_login_config;
-- ----------------------------
-- 平台级移动端应用配置(按 app_type + platform 维度, 每组合一条)
-- app_config/notify_config 使用 text + DataEncryptTypeHandler 加密存储(密文非合法JSON, 故不用 jsonb)
-- ----------------------------
CREATE TABLE IF NOT EXISTS pay_platform_mobile_app (
id int8 NOT NULL,
app_type varchar(32) NOT NULL,
platform varchar(32) NOT NULL,
app_name varchar(64),
app_config text,
notify_config text,
binding_enabled bool NOT NULL DEFAULT false,
enabled bool NOT NULL DEFAULT true,
remark varchar(500),
creator int8,
create_time timestamptz(6),
last_modifier int8,
last_modified_time timestamptz(6),
version int4 NOT NULL DEFAULT 0,
deleted bool NOT NULL DEFAULT false,
CONSTRAINT pk_pay_platform_mobile_app PRIMARY KEY (id)
);
COMMENT ON TABLE pay_platform_mobile_app IS '平台级移动端应用配置';
COMMENT ON COLUMN pay_platform_mobile_app.id IS '主键';
COMMENT ON COLUMN pay_platform_mobile_app.app_type IS '端类型: merchant-商户端 / admin-管理端 / cashier-收银台';
COMMENT ON COLUMN pay_platform_mobile_app.platform IS '移动平台: wx_h5/wx_mini/alipay_mini/dy_mini/android/ios';
COMMENT ON COLUMN pay_platform_mobile_app.app_name IS '应用名称(展示用)';
COMMENT ON COLUMN pay_platform_mobile_app.app_config IS '平台特有密钥配置(JSON文本, AES-256-GCM加密存储)';
COMMENT ON COLUMN pay_platform_mobile_app.notify_config IS '消息通知配置(JSON文本, AES-256-GCM加密存储)';
COMMENT ON COLUMN pay_platform_mobile_app.binding_enabled IS '是否启用第三方账号用户绑定';
COMMENT ON COLUMN pay_platform_mobile_app.enabled IS '是否启用';
COMMENT ON COLUMN pay_platform_mobile_app.remark IS '备注';
COMMENT ON COLUMN pay_platform_mobile_app.creator IS '创建者ID';
COMMENT ON COLUMN pay_platform_mobile_app.create_time IS '创建时间';
COMMENT ON COLUMN pay_platform_mobile_app.last_modifier IS '最后修改者ID';
COMMENT ON COLUMN pay_platform_mobile_app.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN pay_platform_mobile_app.version IS '版本号(乐观锁)';
COMMENT ON COLUMN pay_platform_mobile_app.deleted IS '逻辑删除标记';
CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_platform_mobile_app_type_platform
ON pay_platform_mobile_app (app_type, platform) WHERE deleted = false;

View File

@@ -3,11 +3,16 @@ package cn.daxpay.open.payment.app.mobile.convert;
import cn.daxpay.open.payment.app.mobile.entity.MobileApp;
import cn.daxpay.open.payment.app.mobile.param.MobileAppParam;
import cn.daxpay.open.payment.app.mobile.result.MobileAppResult;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
/// # 移动端应用配置转换
///
/// copy 使用 IGNORE 策略, 前端未传的敏感字段(null)不会覆盖数据库原值,
/// 与前端 diffForm + 后端脱敏回显双重保护配合, 避免误清空。
@Mapper
public interface MobileAppConvert {
@@ -20,5 +25,6 @@ public interface MobileAppConvert {
MobileApp toEntity(MobileAppParam param);
/// 参数拷贝到实体(更新)
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void copy(MobileAppParam param, @MappingTarget MobileApp entity);
}

View File

@@ -4,7 +4,8 @@ import cn.daxpay.open.payment.app.mobile.convert.MobileAppConvert;
import cn.daxpay.open.payment.app.mobile.result.MobileAppResult;
import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import cn.daxpay.open.platform.common.mybatisplus.handler.type.JsonbStringTypeHandler;
import cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -13,7 +14,7 @@ import lombok.experimental.Accessors;
/// # 移动端应用配置
///
/// 平台级移动端应用配置, 按端类型(appType)+移动平台(platform)维度, 每组合一条记录。
/// app_config 存储各平台特有的密钥配置(jsonb), notify_config 存储消息通知配置(jsonb)
/// app_config/notify_config 以 JSON 文本存储, 通过 [DataEncryptTypeHandler] AES-256-GCM 加密入库
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@@ -31,12 +32,12 @@ public class MobileApp extends MpBaseEntity implements ToResult<MobileAppResult>
/// 应用名称(展示用)
private String appName;
/// 平台特有密钥配置(jsonb原始JSON文本)
@com.baomidou.mybatisplus.annotation.TableField(typeHandler = JsonbStringTypeHandler.class)
/// 平台特有密钥配置(JSON文本, 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String appConfig;
/// 消息通知配置(jsonb原始JSON文本)
@com.baomidou.mybatisplus.annotation.TableField(typeHandler = JsonbStringTypeHandler.class)
/// 消息通知配置(JSON文本, 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String notifyConfig;
/// 是否启用第三方账号用户绑定

View File

@@ -5,44 +5,58 @@ import cn.daxpay.open.payment.app.mobile.dao.MobileAppManager;
import cn.daxpay.open.payment.app.mobile.entity.MobileApp;
import cn.daxpay.open.payment.app.mobile.param.MobileAppParam;
import cn.daxpay.open.payment.app.mobile.result.MobileAppResult;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.type.TypeReference;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/// # 移动端应用配置服务
///
/// 平台级配置, 按端类型(appType)+移动平台(platform)维度管理。
/// app_config/notify_config 经 [cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler] 加密入库;
/// 返回前端时对 app_config 内敏感键(appSecret/privateKey/clientSecret)脱敏;
/// 保存时敏感键为空则保留库中原值(配合前端 diffForm)。
@Slf4j
@Service
@RequiredArgsConstructor
public class MobileAppService {
/// app_config 中需要脱敏/保护的敏感键
private static final Set<String> SENSITIVE_KEYS = Set.of(
"appSecret", "privateKey", "alipayPublicKey", "clientSecret");
private final MobileAppManager manager;
/// 查询全部(前端按端类型分组展示卡片)
public List<MobileAppResult> findAll() {
return manager.findAll().stream()
.map(MobileApp::toResult)
.map(this::toMaskedResult)
.toList();
}
/// 按端类型查询所有平台配置(端详情页Tab列表)
public List<MobileAppResult> findAllByAppType(String appType) {
return manager.findAllByField(MobileApp::getAppType, appType).stream()
.map(MobileApp::toResult)
.map(this::toMaskedResult)
.toList();
}
/// 查询单条
public MobileAppResult findById(Long id) {
// 通用: 移动端应用配置不存在
return manager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.mobile_app.notExist"))
.toResult();
MobileApp entity = manager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.mobile_app.notExist"));
return toMaskedResult(entity);
}
/// 保存(按端类型+平台组合 upsert)
@@ -54,13 +68,15 @@ public class MobileAppService {
.oneOpt();
if (existing.isPresent()) {
var entity = existing.get();
// 敏感键为空时保留库中原值, 避免 diffForm 跳过字段后被清空
param.setAppConfig(mergeSensitiveJson(entity.getAppConfig(), param.getAppConfig()));
MobileAppConvert.CONVERT.copy(param, entity);
manager.updateById(entity);
return entity.toResult();
return toMaskedResult(entity);
}
var entity = MobileAppConvert.CONVERT.toEntity(param);
manager.save(entity);
return entity.toResult();
return toMaskedResult(entity);
}
/// 更新启用状态
@@ -72,4 +88,70 @@ public class MobileAppService {
entity.setEnabled(enabled);
manager.updateById(entity);
}
/// 实体转结果并对 app_config 敏感键脱敏
private MobileAppResult toMaskedResult(MobileApp entity) {
MobileAppResult result = entity.toResult();
result.setAppConfig(maskSensitiveJson(result.getAppConfig()));
return result;
}
/// 对 JSON 文本中的敏感键做脱敏(password 风格), 非 JSON 或空串原样返回
private String maskSensitiveJson(String json) {
if (StrUtil.isBlank(json)) {
return json;
}
try {
Map<String, Object> map = JacksonUtil.toBean(json, new TypeReference<Map<String, Object>>() {});
if (map == null || map.isEmpty()) {
return json;
}
boolean changed = false;
for (String key : SENSITIVE_KEYS) {
Object val = map.get(key);
if (val instanceof String s && StrUtil.isNotBlank(s)) {
map.put(key, DesensitizedUtil.password(s));
changed = true;
}
}
return changed ? JacksonUtil.toJson(map) : json;
} catch (Exception e) {
log.warn("maskSensitiveJson parse failed, return raw: {}", e.getMessage());
return json;
}
}
/// 合并新旧 app_config: 新值中敏感键为空/null 时沿用旧值
private String mergeSensitiveJson(String oldJson, String newJson) {
if (StrUtil.isBlank(newJson)) {
// 前端未传则整段保留旧值
return oldJson;
}
if (StrUtil.isBlank(oldJson)) {
return newJson;
}
try {
Map<String, Object> oldMap = JacksonUtil.toBean(oldJson, new TypeReference<Map<String, Object>>() {});
Map<String, Object> newMap = JacksonUtil.toBean(newJson, new TypeReference<Map<String, Object>>() {});
if (oldMap == null) {
oldMap = new LinkedHashMap<>();
}
if (newMap == null) {
return oldJson;
}
for (String key : SENSITIVE_KEYS) {
Object newVal = newMap.get(key);
if (newVal == null || (newVal instanceof String s && StrUtil.isBlank(s))) {
Object oldVal = oldMap.get(key);
if (oldVal != null) {
newMap.put(key, oldVal);
}
}
}
return JacksonUtil.toJson(newMap);
} catch (Exception e) {
log.warn("mergeSensitiveJson parse failed, use newJson: {}", e.getMessage());
return newJson;
}
}
}