mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-12 07:25:39 +08:00
feat(mobile): 移动端应用配置加密存储与敏感字段脱敏
完善平台级移动端应用配置: app_config/notify_config 改 DataEncryptTypeHandler 加密入库, 返回脱敏, 保存时敏感键空值保留原值; 补建 pay_platform_mobile_app 表。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 是否启用第三方账号用户绑定
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user