feat(system): 增强平台移动端应用配置与校验

完善 MobileApp 配置读写、错误与校验文案, 补充 mobile 相关能力。
This commit is contained in:
DaxPay Dev
2026-07-11 10:21:27 +08:00
parent 1516c46924
commit 85dede385b
19 changed files with 675 additions and 101 deletions

View File

@@ -1,5 +1,10 @@
{
"notExist": "Mobile app configuration does not exist",
"notEnabled": "Mobile app configuration is disabled: {0}/{1}",
"appTypeNotFound": "No matching mobile app type found: {0}",
"platformNotFound": "No matching mobile platform found: {0}"
"platformNotFound": "No matching mobile platform found: {0}",
"platformNotAllowed": "App type {0} does not support mobile platform {1}",
"configRequired": "Please submit app config for platform {0}",
"configPlatformMismatch": "App config does not match platform {0}",
"alipayCertRequired": "In certificate mode, please upload app cert, Alipay cert and root cert"
}

View File

@@ -1,5 +1,10 @@
{
"notExist": "移动端应用配置不存在",
"notEnabled": "移动端应用配置未启用: {0}/{1}",
"appTypeNotFound": "未找到对应的移动端应用端类型: {0}",
"platformNotFound": "未找到对应的移动端平台: {0}"
"platformNotFound": "未找到对应的移动端平台: {0}",
"platformNotAllowed": "端类型 {0} 不支持移动平台 {1}",
"configRequired": "请提交与平台 {0} 对应的应用配置",
"configPlatformMismatch": "应用配置与平台 {0} 不匹配",
"alipayCertRequired": "证书模式下请上传应用公钥证书、支付宝公钥证书与根证书"
}

View File

@@ -53,6 +53,15 @@ public class MobileAppController {
return Res.ok(mobileAppService.findById(id));
}
@PermCode(code = "view", nameCn = "移动端应用查看", nameEn = "Mobile App View")
@Operation(summary = "按端类型+平台查询(不存在返回 null)")
@GetMapping("/get-by-type-platform")
public Result<MobileAppResult> findByAppTypeAndPlatform(
@NotBlank(message = "{validation.field.appType.notBlank}") String appType,
@NotBlank(message = "{validation.field.platform.notBlank}") String platform) {
return Res.ok(mobileAppService.findByAppTypeAndPlatform(appType, platform).orElse(null));
}
@PermCode(code = "manage", nameCn = "移动端应用管理", nameEn = "Mobile App Manage")
@Operation(summary = "保存(按端类型+平台 upsert)")
@PostMapping("/save")

View File

@@ -5,26 +5,32 @@ import cn.daxpay.open.platform.system.param.mobile.MobileAppParam;
import cn.daxpay.open.platform.system.result.mobile.MobileAppResult;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
/// # 移动端应用配置转换
/// # 移动端应用配置外壳转换
///
/// copy 使用 IGNORE 策略, 前端未传的敏感字段(null)不会覆盖数据库原值,
/// 与前端 diffForm + 后端脱敏回显双重保护配合, 避免误清空
/// 嵌套平台配置与 appConfig 密文由 [cn.daxpay.open.platform.system.service.mobile.MobileAppService] 处理,
/// 此处仅映射外壳字段
@Mapper
public interface MobileAppConvert {
MobileAppConvert CONVERT = Mappers.getMapper(MobileAppConvert.class);
/// 实体转结果
/// 实体转结果(嵌套配置由 Service 填充)
@Mapping(target = "wxMini", ignore = true)
@Mapping(target = "alipayMini", ignore = true)
@Mapping(target = "dyMini", ignore = true)
MobileAppResult toResult(MobileApp entity);
/// 参数转实体(新增)
/// 参数转实体(新增, appConfig 由 Service 序列化后写入)
@Mapping(target = "appConfig", ignore = true)
MobileApp toEntity(MobileAppParam param);
/// 参数拷贝到实体(更新)
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "appConfig", ignore = true)
void copy(MobileAppParam param, @MappingTarget MobileApp entity);
}

View File

@@ -4,7 +4,25 @@ import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.platform.system.entity.mobile.MobileApp;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/// # 移动端应用配置管理器
@Repository
public class MobileAppManager extends BaseManager<MobileAppMapper, MobileApp> {
/// 按端类型 + 移动平台查询(唯一键)
public Optional<MobileApp> findByAppTypeAndPlatform(String appType, String platform) {
return lambdaQuery()
.eq(MobileApp::getAppType, appType)
.eq(MobileApp::getPlatform, platform)
.oneOpt();
}
/// 按端类型查询全部平台配置
public List<MobileApp> findAllByAppType(String appType) {
return lambdaQuery()
.eq(MobileApp::getAppType, appType)
.list();
}
}

View File

@@ -3,6 +3,7 @@ package cn.daxpay.open.platform.system.entity.mobile;
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.encrypt.DataEncryptTypeHandler;
import cn.daxpay.open.platform.common.mybatisplus.handler.type.JsonbStringTypeHandler;
import cn.daxpay.open.platform.system.convert.mobile.MobileAppConvert;
import cn.daxpay.open.platform.system.result.mobile.MobileAppResult;
import com.baomidou.mybatisplus.annotation.TableField;
@@ -14,7 +15,8 @@ import lombok.experimental.Accessors;
/// # 移动端应用配置
///
/// 平台级移动端应用配置, 按端类型(appType)+移动平台(platform)维度, 每组合一条记录。
/// app_config/notify_config 以 JSON 文本存储, 通过 [DataEncryptTypeHandler] AES-256-GCM 加密入库
/// - app_config: text, 含密钥, 经 [DataEncryptTypeHandler] AES-256-GCM 加密入库
/// - notify_config: jsonb, 通知模板/开关等非敏感配置, 经 [JsonbStringTypeHandler] 明文存储
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@@ -29,15 +31,12 @@ public class MobileApp extends MpBaseEntity implements ToResult<MobileAppResult>
/// @see cn.daxpay.open.platform.system.enums.MobilePlatformEnum
private String platform;
/// 应用名称(展示用)
private String appName;
/// 平台特有密钥配置(JSON文本, 加密存储)
/// 平台特有密钥配置(JSON文本, 加密存储为 text)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String appConfig;
/// 消息通知配置(JSON文本, 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
/// 消息通知配置(jsonb, 非敏感, 明文; Java 侧为原始 JSON 字符串)
@TableField(typeHandler = JsonbStringTypeHandler.class)
private String notifyConfig;
/// 是否启用第三方账号用户绑定

View File

@@ -0,0 +1,34 @@
package cn.daxpay.open.platform.system.mobile.config;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 支付宝小程序应用配置(落库 JSON 形状)
///
/// 序列化后写入 [cn.daxpay.open.platform.system.entity.mobile.MobileApp#appConfig] 并加密存储。
/// authType 与平台支付宝认证一致: public_key / cert。
@Data
@Accessors(chain = true)
public class AlipayMiniAppConfig {
/// 小程序 AppId
private String appId;
/// 鉴权方式: public_key(公钥) / cert(证书)
private String authType;
/// 应用私钥
private String privateKey;
/// 支付宝公钥(公钥模式)
private String alipayPublicKey;
/// 应用公钥证书(证书模式)
private String appCert;
/// 支付宝公钥证书(证书模式)
private String alipayCert;
/// 支付宝根证书(证书模式)
private String alipayRootCert;
}

View File

@@ -0,0 +1,18 @@
package cn.daxpay.open.platform.system.mobile.config;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音小程序应用配置(落库 JSON 形状, 预留)
///
/// 序列化后写入 [cn.daxpay.open.platform.system.entity.mobile.MobileApp#appConfig] 并加密存储。
@Data
@Accessors(chain = true)
public class DyMiniAppConfig {
/// 小程序 AppId
private String appId;
/// 小程序 AppSecret
private String appSecret;
}

View File

@@ -0,0 +1,21 @@
package cn.daxpay.open.platform.system.mobile.config;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信小程序应用配置(落库 JSON 形状)
///
/// 序列化后写入 [cn.daxpay.open.platform.system.entity.mobile.MobileApp#appConfig] 并加密存储。
@Data
@Accessors(chain = true)
public class WxMiniAppConfig {
/// 小程序 AppId
private String appId;
/// 小程序 AppSecret
private String appSecret;
/// 原始 ID(gh_ 开头, 可选)
private String originalId;
}

View File

@@ -0,0 +1,46 @@
package cn.daxpay.open.platform.system.mobile.config.convert;
import cn.daxpay.open.platform.system.mobile.config.AlipayMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.DyMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.WxMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.param.AlipayMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.DyMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.WxMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.result.AlipayMiniAppConfigResult;
import cn.daxpay.open.platform.system.mobile.config.result.DyMiniAppConfigResult;
import cn.daxpay.open.platform.system.mobile.config.result.WxMiniAppConfigResult;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
/// # 移动端各平台应用配置转换
///
/// Param → Config 使用 IGNORE, 空敏感字段不覆盖目标(merge 时先 copy 旧值再 overlay)。
@Mapper
public interface MobileAppConfigConvert {
MobileAppConfigConvert CONVERT = Mappers.getMapper(MobileAppConfigConvert.class);
WxMiniAppConfig toConfig(WxMiniAppConfigParam param);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void copy(WxMiniAppConfigParam param, @MappingTarget WxMiniAppConfig config);
WxMiniAppConfigResult toResult(WxMiniAppConfig config);
AlipayMiniAppConfig toConfig(AlipayMiniAppConfigParam param);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void copy(AlipayMiniAppConfigParam param, @MappingTarget AlipayMiniAppConfig config);
AlipayMiniAppConfigResult toResult(AlipayMiniAppConfig config);
DyMiniAppConfig toConfig(DyMiniAppConfigParam param);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void copy(DyMiniAppConfigParam param, @MappingTarget DyMiniAppConfig config);
DyMiniAppConfigResult toResult(DyMiniAppConfig config);
}

View File

@@ -0,0 +1,38 @@
package cn.daxpay.open.platform.system.mobile.config.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 支付宝小程序应用配置入参
///
/// 敏感字段为空表示不更新; 公钥/证书材料互斥校验在 Service 按新建/更新处理。
@Data
@Accessors(chain = true)
@Schema(title = "支付宝小程序应用配置参数")
public class AlipayMiniAppConfigParam {
@NotBlank(message = "{validation.field.appId.notBlank}")
@Schema(description = "小程序 AppId")
private String appId;
@NotBlank(message = "{validation.field.authType.notBlank}")
@Schema(description = "鉴权方式: public_key / cert")
private String authType;
@Schema(description = "应用私钥(空则不更新)")
private String privateKey;
@Schema(description = "支付宝公钥(公钥模式, 空则不更新)")
private String alipayPublicKey;
@Schema(description = "应用公钥证书(证书模式, 空则不更新)")
private String appCert;
@Schema(description = "支付宝公钥证书(证书模式, 空则不更新)")
private String alipayCert;
@Schema(description = "支付宝根证书(证书模式, 空则不更新)")
private String alipayRootCert;
}

View File

@@ -0,0 +1,20 @@
package cn.daxpay.open.platform.system.mobile.config.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音小程序应用配置入参(预留)
@Data
@Accessors(chain = true)
@Schema(title = "抖音小程序应用配置参数")
public class DyMiniAppConfigParam {
@NotBlank(message = "{validation.field.appId.notBlank}")
@Schema(description = "小程序 AppId")
private String appId;
@Schema(description = "小程序 AppSecret(空则不更新)")
private String appSecret;
}

View File

@@ -0,0 +1,25 @@
package cn.daxpay.open.platform.system.mobile.config.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信小程序应用配置入参
///
/// 敏感字段 appSecret 为空表示不更新(配合前端 diffForm)。
@Data
@Accessors(chain = true)
@Schema(title = "微信小程序应用配置参数")
public class WxMiniAppConfigParam {
@NotBlank(message = "{validation.field.appId.notBlank}")
@Schema(description = "小程序 AppId")
private String appId;
@Schema(description = "小程序 AppSecret(空则不更新)")
private String appSecret;
@Schema(description = "原始 ID(gh_ 开头, 可选)")
private String originalId;
}

View File

@@ -0,0 +1,39 @@
package cn.daxpay.open.platform.system.mobile.config.result;
import cn.daxpay.open.platform.common.json.sensitive.SensitiveInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 支付宝小程序应用配置出参(敏感字段脱敏)
@Data
@Accessors(chain = true)
@Schema(title = "支付宝小程序应用配置")
public class AlipayMiniAppConfigResult {
@Schema(description = "小程序 AppId")
private String appId;
@Schema(description = "鉴权方式: public_key / cert")
private String authType;
@SensitiveInfo
@Schema(description = "应用私钥")
private String privateKey;
@SensitiveInfo
@Schema(description = "支付宝公钥")
private String alipayPublicKey;
@SensitiveInfo
@Schema(description = "应用公钥证书")
private String appCert;
@SensitiveInfo
@Schema(description = "支付宝公钥证书")
private String alipayCert;
@SensitiveInfo
@Schema(description = "支付宝根证书")
private String alipayRootCert;
}

View File

@@ -0,0 +1,20 @@
package cn.daxpay.open.platform.system.mobile.config.result;
import cn.daxpay.open.platform.common.json.sensitive.SensitiveInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音小程序应用配置出参(预留, 敏感字段脱敏)
@Data
@Accessors(chain = true)
@Schema(title = "抖音小程序应用配置")
public class DyMiniAppConfigResult {
@Schema(description = "小程序 AppId")
private String appId;
@SensitiveInfo
@Schema(description = "小程序 AppSecret")
private String appSecret;
}

View File

@@ -0,0 +1,23 @@
package cn.daxpay.open.platform.system.mobile.config.result;
import cn.daxpay.open.platform.common.json.sensitive.SensitiveInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信小程序应用配置出参(敏感字段脱敏)
@Data
@Accessors(chain = true)
@Schema(title = "微信小程序应用配置")
public class WxMiniAppConfigResult {
@Schema(description = "小程序 AppId")
private String appId;
@SensitiveInfo
@Schema(description = "小程序 AppSecret")
private String appSecret;
@Schema(description = "原始 ID")
private String originalId;
}

View File

@@ -1,33 +1,53 @@
package cn.daxpay.open.platform.system.param.mobile;
import cn.daxpay.open.platform.system.mobile.config.param.AlipayMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.DyMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.WxMiniAppConfigParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 移动端应用配置参数
///
/// 保存时按 appType + platform 进行 upsert, 二者组合为唯一键。
/// 保存时按 appType + platform 进行 upsert。平台密钥走嵌套强类型对象, 与 platform 一一对应:
/// - wx_mini → wxMini
/// - alipay_mini → alipayMini
/// - dy_mini → dyMini
@Data
@Accessors(chain = true)
@Schema(title = "移动端应用配置参数")
public class MobileAppParam {
@Schema(description = "主键(更新时必填)")
@Schema(description = "主键(更新时)")
private Long id;
@Schema(description = "端类型")
/// 端类型
/// @see cn.daxpay.open.platform.system.enums.MobileAppTypeEnum
@NotBlank(message = "{validation.field.appType.notBlank}")
@Schema(description = "端类型: merchant/admin/cashier")
private String appType;
@Schema(description = "移动平台")
/// 移动平台
/// @see cn.daxpay.open.platform.system.enums.MobilePlatformEnum
@NotBlank(message = "{validation.field.platform.notBlank}")
@Schema(description = "移动平台: wx_h5/wx_mini/alipay_mini/dy_mini/android/ios")
private String platform;
@Schema(description = "应用名称")
private String appName;
@Valid
@Schema(description = "微信小程序配置(platform=wx_mini 时必填)")
private WxMiniAppConfigParam wxMini;
@Schema(description = "平台特有密钥配置(JSON文本)")
private String appConfig;
@Valid
@Schema(description = "支付宝小程序配置(platform=alipay_mini 时必填)")
private AlipayMiniAppConfigParam alipayMini;
@Schema(description = "消息通知配置(JSON文本)")
@Valid
@Schema(description = "抖音小程序配置(platform=dy_mini 时必填, 预留)")
private DyMiniAppConfigParam dyMini;
@Schema(description = "消息通知配置(jsonb 原始JSON文本, 明文非敏感)")
private String notifyConfig;
@Schema(description = "是否启用第三方账号用户绑定")

View File

@@ -1,12 +1,17 @@
package cn.daxpay.open.platform.system.result.mobile;
import cn.daxpay.open.platform.core.result.BaseResult;
import cn.daxpay.open.platform.system.mobile.config.result.AlipayMiniAppConfigResult;
import cn.daxpay.open.platform.system.mobile.config.result.DyMiniAppConfigResult;
import cn.daxpay.open.platform.system.mobile.config.result.WxMiniAppConfigResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 移动端应用配置结果
///
/// 平台密钥以嵌套强类型返回(敏感字段脱敏), 按 platform 仅填充对应嵌套。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@@ -19,13 +24,16 @@ public class MobileAppResult extends BaseResult {
@Schema(description = "移动平台")
private String platform;
@Schema(description = "应用名称")
private String appName;
@Schema(description = "微信小程序配置")
private WxMiniAppConfigResult wxMini;
@Schema(description = "平台特有密钥配置(JSON文本)")
private String appConfig;
@Schema(description = "支付宝小程序配置")
private AlipayMiniAppConfigResult alipayMini;
@Schema(description = "消息通知配置(JSON文本)")
@Schema(description = "抖音小程序配置")
private DyMiniAppConfigResult dyMini;
@Schema(description = "消息通知配置(jsonb 原始JSON文本, 明文非敏感)")
private String notifyConfig;
@Schema(description = "是否启用第三方账号用户绑定")

View File

@@ -1,41 +1,64 @@
package cn.daxpay.open.platform.system.service.mobile;
import cn.daxpay.open.platform.capability.alipay.auth.config.AlipayAuthTypeEnum;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.system.convert.mobile.MobileAppConvert;
import cn.daxpay.open.platform.system.dao.mobile.MobileAppManager;
import cn.daxpay.open.platform.system.entity.mobile.MobileApp;
import cn.daxpay.open.platform.system.enums.MobileAppTypeEnum;
import cn.daxpay.open.platform.system.enums.MobilePlatformEnum;
import cn.daxpay.open.platform.system.mobile.config.AlipayMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.DyMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.WxMiniAppConfig;
import cn.daxpay.open.platform.system.mobile.config.convert.MobileAppConfigConvert;
import cn.daxpay.open.platform.system.mobile.config.param.AlipayMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.DyMiniAppConfigParam;
import cn.daxpay.open.platform.system.mobile.config.param.WxMiniAppConfigParam;
import cn.daxpay.open.platform.system.param.mobile.MobileAppParam;
import cn.daxpay.open.platform.system.result.mobile.MobileAppResult;
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.Optional;
import java.util.Set;
/// # 移动端应用配置服务
///
/// 平台级配置, 按端类型(appType)+移动平台(platform)维度管理。
/// app_config/notify_config 经 [cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler] 加密入库;
/// 返回前端时对 app_config 内敏感键(appSecret/privateKey/alipayPublicKey/证书等)脱敏;
/// 保存时敏感键为空则保留库中原值(配合前端 diffForm)。
/// 平台级配置, 按端类型(appType)+移动平台(platform)维度管理, 每组合最多一条(唯一键)
/// - app_config: 强类型 Config 序列化后经 [DataEncryptTypeHandler] 加密入库
/// - API 层: wxMini / alipayMini / dyMini 嵌套对象; notify_config 仍为明文 jsonb 字符串
///
/// ## 端类型与平台白名单
/// - merchant: wx_h5 / wx_mini / alipay_mini / dy_mini
/// - admin: wx_mini / alipay_mini / dy_mini
/// - cashier: wx_mini / alipay_mini(抖音本期未开放)
@Slf4j
@Service
@RequiredArgsConstructor
public class MobileAppService {
/// app_config 中需要脱敏/保护的敏感键
/// 含支付宝小程序公钥字段与证书模式三本证书
private static final Set<String> SENSITIVE_KEYS = Set.of(
"appSecret", "privateKey", "alipayPublicKey", "clientSecret",
"appCert", "alipayCert", "alipayRootCert");
/// 各端类型允许配置的移动平台(本期开放范围)
private static final Map<MobileAppTypeEnum, Set<MobilePlatformEnum>> ALLOWED_PLATFORMS = Map.of(
MobileAppTypeEnum.MERCHANT, Set.of(
MobilePlatformEnum.WX_H5,
MobilePlatformEnum.WX_MINI,
MobilePlatformEnum.ALIPAY_MINI,
MobilePlatformEnum.DY_MINI),
MobileAppTypeEnum.ADMIN, Set.of(
MobilePlatformEnum.WX_MINI,
MobilePlatformEnum.ALIPAY_MINI,
MobilePlatformEnum.DY_MINI),
MobileAppTypeEnum.CASHIER, Set.of(
MobilePlatformEnum.WX_MINI,
MobilePlatformEnum.ALIPAY_MINI));
private final MobileAppManager manager;
@@ -46,37 +69,89 @@ public class MobileAppService {
.toList();
}
/// 按端类型查询所有平台配置(端详情页Tab列表)
/// 按端类型查询所有平台配置(端详情页 Tab 列表)
public List<MobileAppResult> findAllByAppType(String appType) {
return manager.findAllByField(MobileApp::getAppType, appType).stream()
MobileAppTypeEnum.findByCode(appType);
return manager.findAllByAppType(appType).stream()
.map(this::toMaskedResult)
.toList();
}
/// 查询单条
/// 查询单条(运营端, 脱敏)
public MobileAppResult findById(Long id) {
// 通用: 移动端应用配置不存在
MobileApp entity = manager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.mobile_app.notExist"));
return toMaskedResult(entity);
}
/// 按端类型+平台查询(运营端, 脱敏; 不存在返回 empty)
public Optional<MobileAppResult> findByAppTypeAndPlatform(String appType, String platform) {
MobileAppTypeEnum.findByCode(appType);
MobilePlatformEnum.findByCode(platform);
return manager.findByAppTypeAndPlatform(appType, platform)
.map(this::toMaskedResult);
}
/// 运行时读取实体(不脱敏, appConfig 为解密后 JSON 明文)
public MobileApp getEntity(String appType, String platform) {
return manager.findByAppTypeAndPlatform(appType, platform)
.orElseThrow(() -> new DataNotExistException("error.mobile_app.notExist"));
}
/// 运行时读取已启用的配置实体
public MobileApp getEnabledEntity(String appType, String platform) {
MobileApp entity = getEntity(appType, platform);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.notEnabled", appType, platform);
}
return entity;
}
/// 运行时: 解析微信小程序配置(明文密钥)
public WxMiniAppConfig getWxMiniConfig(String appType) {
return parseConfig(getEnabledEntity(appType, MobilePlatformEnum.WX_MINI.getCode()).getAppConfig(),
WxMiniAppConfig.class);
}
/// 运行时: 解析支付宝小程序配置(明文密钥)
public AlipayMiniAppConfig getAlipayMiniConfig(String appType) {
return parseConfig(getEnabledEntity(appType, MobilePlatformEnum.ALIPAY_MINI.getCode()).getAppConfig(),
AlipayMiniAppConfig.class);
}
/// 运行时: 解析抖音小程序配置(明文密钥)
public DyMiniAppConfig getDyMiniConfig(String appType) {
return parseConfig(getEnabledEntity(appType, MobilePlatformEnum.DY_MINI.getCode()).getAppConfig(),
DyMiniAppConfig.class);
}
/// 保存(按端类型+平台组合 upsert)
@Transactional(rollbackFor = Exception.class)
public MobileAppResult save(MobileAppParam param) {
var existing = manager.lambdaQuery()
.eq(MobileApp::getAppType, param.getAppType())
.eq(MobileApp::getPlatform, param.getPlatform())
.oneOpt();
MobilePlatformEnum platform = validateSaveParam(param);
String appConfigJson = buildAppConfigJson(param, platform,
manager.findByAppTypeAndPlatform(param.getAppType(), param.getPlatform())
.map(MobileApp::getAppConfig)
.orElse(null));
var existing = manager.findByAppTypeAndPlatform(param.getAppType(), param.getPlatform());
if (existing.isPresent()) {
var entity = existing.get();
// 敏感键为空时保留库中原值, 避免 diffForm 跳过字段后被清空
param.setAppConfig(mergeSensitiveJson(entity.getAppConfig(), param.getAppConfig()));
MobileAppConvert.CONVERT.copy(param, entity);
entity.setAppConfig(appConfigJson);
manager.updateById(entity);
return toMaskedResult(entity);
}
var entity = MobileAppConvert.CONVERT.toEntity(param);
entity.setAppConfig(appConfigJson);
if (entity.getEnabled() == null) {
entity.setEnabled(true);
}
if (entity.getBindingEnabled() == null) {
entity.setBindingEnabled(false);
}
manager.save(entity);
return toMaskedResult(entity);
}
@@ -85,75 +160,220 @@ public class MobileAppService {
@Transactional(rollbackFor = Exception.class)
public void updateEnabled(Long id, Boolean enabled) {
var entity = manager.findById(id)
// 通用: 移动端应用配置不存在
.orElseThrow(() -> new DataNotExistException("error.mobile_app.notExist"));
entity.setEnabled(enabled);
manager.updateById(entity);
}
/// 实体转结果并对 app_config 敏感键脱敏
/// 保存前校验白名单 + platform 与嵌套对象匹配
private MobilePlatformEnum validateSaveParam(MobileAppParam param) {
MobileAppTypeEnum appType = MobileAppTypeEnum.findByCode(param.getAppType());
MobilePlatformEnum platform = MobilePlatformEnum.findByCode(param.getPlatform());
Set<MobilePlatformEnum> allowed = ALLOWED_PLATFORMS.get(appType);
if (allowed == null || !allowed.contains(platform)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.platformNotAllowed",
appType.getCode(), platform.getCode());
}
// 统计传入的嵌套配置数量, 必须恰好 1 个且与 platform 对应
int nestedCount = 0;
if (param.getWxMini() != null) {
nestedCount++;
}
if (param.getAlipayMini() != null) {
nestedCount++;
}
if (param.getDyMini() != null) {
nestedCount++;
}
if (nestedCount != 1) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.configRequired", platform.getCode());
}
boolean match = switch (platform) {
case WX_MINI -> param.getWxMini() != null;
case ALIPAY_MINI -> param.getAlipayMini() != null;
case DY_MINI -> param.getDyMini() != null;
default -> false;
};
if (!match) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.configPlatformMismatch", platform.getCode());
}
return platform;
}
/// 按平台合并并序列化 app_config JSON
private String buildAppConfigJson(MobileAppParam param, MobilePlatformEnum platform, String oldJson) {
return switch (platform) {
case WX_MINI -> {
WxMiniAppConfig merged = mergeWx(oldJson, param.getWxMini());
validateWxForCreate(merged, oldJson);
yield JacksonUtil.toJson(merged);
}
case ALIPAY_MINI -> {
AlipayMiniAppConfig merged = mergeAlipay(oldJson, param.getAlipayMini());
validateAlipayForCreate(merged, oldJson);
yield JacksonUtil.toJson(merged);
}
case DY_MINI -> {
DyMiniAppConfig merged = mergeDy(oldJson, param.getDyMini());
validateDyForCreate(merged, oldJson);
yield JacksonUtil.toJson(merged);
}
default -> throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.configPlatformMismatch", platform.getCode());
};
}
private WxMiniAppConfig mergeWx(String oldJson, WxMiniAppConfigParam param) {
WxMiniAppConfig config = parseConfigOrNew(oldJson, WxMiniAppConfig.class);
// appId 必填, 总是覆盖
config.setAppId(param.getAppId());
if (StrUtil.isNotBlank(param.getAppSecret())) {
config.setAppSecret(param.getAppSecret());
}
// originalId 允许传空串清空
if (param.getOriginalId() != null) {
config.setOriginalId(param.getOriginalId());
}
return config;
}
private AlipayMiniAppConfig mergeAlipay(String oldJson, AlipayMiniAppConfigParam param) {
AlipayMiniAppConfig config = parseConfigOrNew(oldJson, AlipayMiniAppConfig.class);
config.setAppId(param.getAppId());
config.setAuthType(param.getAuthType());
if (StrUtil.isNotBlank(param.getPrivateKey())) {
config.setPrivateKey(param.getPrivateKey());
}
if (StrUtil.isNotBlank(param.getAlipayPublicKey())) {
config.setAlipayPublicKey(param.getAlipayPublicKey());
}
if (StrUtil.isNotBlank(param.getAppCert())) {
config.setAppCert(param.getAppCert());
}
if (StrUtil.isNotBlank(param.getAlipayCert())) {
config.setAlipayCert(param.getAlipayCert());
}
if (StrUtil.isNotBlank(param.getAlipayRootCert())) {
config.setAlipayRootCert(param.getAlipayRootCert());
}
return config;
}
private DyMiniAppConfig mergeDy(String oldJson, DyMiniAppConfigParam param) {
DyMiniAppConfig config = parseConfigOrNew(oldJson, DyMiniAppConfig.class);
config.setAppId(param.getAppId());
if (StrUtil.isNotBlank(param.getAppSecret())) {
config.setAppSecret(param.getAppSecret());
}
return config;
}
/// 新建时强制敏感材料齐全
private void validateWxForCreate(WxMiniAppConfig config, String oldJson) {
if (StrUtil.isNotBlank(oldJson)) {
return;
}
if (StrUtil.isBlank(config.getAppSecret())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.appSecret.notBlank");
}
}
private void validateDyForCreate(DyMiniAppConfig config, String oldJson) {
if (StrUtil.isNotBlank(oldJson)) {
return;
}
if (StrUtil.isBlank(config.getAppSecret())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.appSecret.notBlank");
}
}
private void validateAlipayForCreate(AlipayMiniAppConfig config, String oldJson) {
if (StrUtil.isNotBlank(oldJson)) {
return;
}
if (StrUtil.isBlank(config.getPrivateKey())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.privateKey.notBlank");
}
AlipayAuthTypeEnum authType = AlipayAuthTypeEnum.fromCode(config.getAuthType());
if (authType.isCert()) {
if (StrUtil.isBlank(config.getAppCert())
|| StrUtil.isBlank(config.getAlipayCert())
|| StrUtil.isBlank(config.getAlipayRootCert())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.mobile_app.alipayCertRequired");
}
} else if (StrUtil.isBlank(config.getAlipayPublicKey())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.alipayPublicKey.notBlank");
}
}
/// 实体 → 脱敏结果(填充对应嵌套配置)
private MobileAppResult toMaskedResult(MobileApp entity) {
MobileAppResult result = entity.toResult();
result.setAppConfig(maskSensitiveJson(result.getAppConfig()));
MobileAppResult result = MobileAppConvert.CONVERT.toResult(entity);
String json = entity.getAppConfig();
if (StrUtil.isBlank(json) || StrUtil.isBlank(entity.getPlatform())) {
return result;
}
MobilePlatformEnum platform;
try {
platform = MobilePlatformEnum.findByCode(entity.getPlatform());
} catch (Exception e) {
return result;
}
try {
switch (platform) {
case WX_MINI -> result.setWxMini(
MobileAppConfigConvert.CONVERT.toResult(parseConfig(json, WxMiniAppConfig.class)));
case ALIPAY_MINI -> result.setAlipayMini(
MobileAppConfigConvert.CONVERT.toResult(parseConfig(json, AlipayMiniAppConfig.class)));
case DY_MINI -> result.setDyMini(
MobileAppConfigConvert.CONVERT.toResult(parseConfig(json, DyMiniAppConfig.class)));
default -> {
}
}
} catch (Exception e) {
log.warn("parse appConfig for result failed, platform={}: {}", entity.getPlatform(), e.getMessage());
}
return result;
}
/// 对 JSON 文本中的敏感键做脱敏(password 风格), 非 JSON 或空串原样返回
private String maskSensitiveJson(String json) {
private <T> T parseConfig(String json, Class<T> type) {
if (StrUtil.isBlank(json)) {
return json;
throw new DataNotExistException("error.mobile_app.notExist");
}
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;
T config = JacksonUtil.toBean(json, type);
if (config == null) {
throw new DataNotExistException("error.mobile_app.notExist");
}
return config;
}
/// 合并新旧 app_config: 新值中敏感键为空/null 时沿用旧值
private String mergeSensitiveJson(String oldJson, String newJson) {
if (StrUtil.isBlank(newJson)) {
// 前端未传则整段保留旧值
return oldJson;
private <T> T parseConfigOrNew(String json, Class<T> type) {
if (StrUtil.isBlank(json)) {
try {
return type.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new IllegalStateException("create config failed: " + type.getName(), e);
}
}
if (StrUtil.isBlank(oldJson)) {
return newJson;
T config = JacksonUtil.toBean(json, type);
if (config != null) {
return config;
}
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);
return type.getDeclaredConstructor().newInstance();
} catch (Exception e) {
log.warn("mergeSensitiveJson parse failed, use newJson: {}", e.getMessage());
return newJson;
throw new IllegalStateException("create config failed: " + type.getName(), e);
}
}
}