Просмотр исходного кода

feat(cfc): 多小程序配置 P0(T01 配置模型 MiniAppProperties/MiniAppStaticConfig/MiniAppConfig + T02 MiniAppInterceptor/上下文 + T03 User.appid/WxApiClient/WxPayClient/UserServiceOpenidIsolationTest)

Agnes Agent 1 день назад
Родитель
Сommit
a1bc2b9fe8
25 измененных файлов с 1846 добавлено и 10 удалено
  1. 196 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java
  3. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  4. 80 0
      cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppInterceptor.java
  5. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppProperties.java
  6. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppStaticConfig.java
  7. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  8. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContext.java
  9. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContextHolder.java
  10. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContextResolver.java
  11. 108 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MiniAppConfig.java
  12. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PackageOrder.java
  13. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  14. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
  15. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MiniAppMapper.java
  16. 251 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MiniAppConfigService.java
  17. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  18. 31 10
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  19. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java
  20. 201 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WxApiClient.java
  21. 172 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WxPayClient.java
  22. 8 0
      cfc-backend/src/main/resources/application.yml
  23. 36 0
      cfc-backend/src/main/resources/schema.sql
  24. 145 0
      cfc-backend/src/test/java/com/etotem/cfc/context/MiniAppContextResolverTest.java
  25. 169 0
      cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceOpenidIsolationTest.java

+ 196 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -49,9 +49,65 @@ public class DatabaseInitializer implements CommandLineRunner {
     @Resource
     private CfLegacyLedgerMigrationService cfLegacyLedgerMigrationService;
 
+    @Resource
+    private com.etotem.cfc.service.MiniAppConfigService miniAppConfigService;
+
     @Value("${app.init-test-data:false}")
     private boolean initTestData;
 
+    // ===== 多小程序配置(迁移465 播种 default 行时从 yml wechat:* 读取初始值)=====
+    @Value("${wechat.appid:wx5ba8038ef16fb245}")
+    private String wechatAppid;
+
+    @Value("${wechat.mch-id:}")
+    private String wechatMchId;
+
+    @Value("${wechat.mch-serial-no:}")
+    private String wechatMchSerialNo;
+
+    @Value("${wechat.private-key-path:}")
+    private String wechatPrivateKeyPath;
+
+    @Value("${wechat.notify-url:}")
+    private String wechatNotifyUrl;
+
+    @Value("${wechat.login-url:}")
+    private String wechatLoginUrl;
+
+    @Value("${wechat.phone-url:}")
+    private String wechatPhoneUrl;
+
+    @Value("${wechat.test-mode:false}")
+    private boolean wechatTestMode;
+
+    @Value("${wechat.fulfillment-enabled:true}")
+    private boolean wechatFulfillmentEnabled;
+
+    @Value("${wechat.virtual-pay-env:0}")
+    private int wechatVirtualPayEnv;
+
+    @Value("${wechat.offer-id:}")
+    private String wechatOfferId;
+
+    @Value("${wechat.alert-template-id:}")
+    private String wechatAlertTemplateId;
+
+    @Value("${wechat.promotion-tier-template-id:}")
+    private String wechatPromotionTierTemplateId;
+
+    @Value("${wechat.env-version:release}")
+    private String wechatEnvVersion;
+
+    @Value("${wechat.wxacode-login-page:pages/login/scan-login}")
+    private String wechatWxacodeLoginPage;
+
+    /** 默认小程序 appid(users/appid 回填兜底值,与 yml apps.default.app-id 一致) */
+    @Value("${wechat.apps.default.app-id:wx5ba8038ef16fb245}")
+    private String defaultAppIdFallback;
+
+    // 运行时读取的 appId(由 MiniAppProperties 优先;yml 未配置时回退上面)
+    // 注:weChatService 的 @Value wechat.appid 已由 Spring 绑定;此处仅做 DDL 回填的兜底常量。
+
     @Override
     public void run(String... args) throws Exception {
         log.info("开始初始化数据库...");
@@ -65,6 +121,13 @@ public class DatabaseInitializer implements CommandLineRunner {
         // 3. 执行数据库迁移(添加新列)
         runMigrations();
 
+        // 迁移完成后刷新多小程序配置缓存(@PostConstruct 时表可能尚未建出, 此处补齐 DB 播种行)
+        try {
+            miniAppConfigService.reload();
+        } catch (Exception e) {
+            log.warn("迁移后刷新多小程序配置缓存失败(不阻塞): {}", e.getMessage());
+        }
+
         // 4. 初始化食品相关表(DDL,始终执行)
         initFoodTables();
 
@@ -3491,6 +3554,139 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移464: 创建 P3 跨案例聚合承载表 attribution_case_strategies(T15 跨案例聚合,S-cfc-119 guhe.p3.lowSideAggregate)
         createAttributionCaseStrategyTable();
+
+        // 迁移465: 多小程序配置表 miniapp_config + 播种 default 行(多小程序方案 §2.1,密钥只存 env 引用名)
+        migrateMiniAppConfig();
+
+        // 迁移466: users / product_orders / package_orders 加 appid 列 + users 唯一索引(OID 防串号)
+        migrateMultiAppDataDimensions();
+    }
+
+
+    /**
+     * 迁移465: 多小程序配置表 miniapp_config + 播种 default 行。
+     *
+     * <p>多小程序对接架构方案 §2.1:密钥类字段只存「env 引用名」不存明文;
+     * default 行为 NULL 引用名(回落 yml {@code wechat.*} 既有取值)。
+     * 幂等:{@code CREATE TABLE IF NOT EXISTS} + {@code INSERT IGNORE}(按
+     * mp_code/app_id 唯一键去重),可重复执行不报 Duplicate。</p>
+     */
+    private void migrateMiniAppConfig() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS miniapp_config ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "mp_code VARCHAR(32) NOT NULL UNIQUE COMMENT '小程序路由标识(稳定短码,路由/回调/日志用),如 default/xiaoyi/zhishao', "
+                    + "display_name VARCHAR(64) COMMENT '展示名,如 CFC主小程序', "
+                    + "app_id VARCHAR(32) NOT NULL UNIQUE COMMENT '微信 appid(区分配置的自然键)', "
+                    + "secret_ref VARCHAR(128) COMMENT 'secret 的 env 引用名(如 WECHAT_SECRET_XIAOYI);NULL=回落 wechat.secret', "
+                    + "mch_id VARCHAR(32) COMMENT '微信支付商户号;NULL=不启用支付', "
+                    + "mch_key_ref VARCHAR(128) COMMENT 'mch-key 的 env 引用名', "
+                    + "api_v3_key_ref VARCHAR(128) COMMENT 'api-v3-key 的 env 引用名', "
+                    + "mch_serial_no VARCHAR(64) COMMENT '商户 API 证书序列号', "
+                    + "private_key_path VARCHAR(256) COMMENT 'apiclient_key.pem classpath 路径(每 appid 一份)', "
+                    + "notify_url VARCHAR(256) COMMENT '支付回调 URL(可带 /{mpCode} 路径)', "
+                    + "login_url VARCHAR(256) DEFAULT 'https://api.weixin.qq.com/sns/jscode2session', "
+                    + "phone_url VARCHAR(256) DEFAULT 'https://api.weixin.qq.com/wxa/business/getuserphonenumber', "
+                    + "test_mode TINYINT(1) DEFAULT 0 COMMENT '测试模式:1=跳过微信 API', "
+                    + "fulfillment_enabled TINYINT(1) DEFAULT 1, "
+                    + "virtual_pay_enabled TINYINT(1) DEFAULT 0, "
+                    + "appkey_ref VARCHAR(128) COMMENT '虚拟支付现网 appkey 的 env 引用名', "
+                    + "sandbox_appkey_ref VARCHAR(128) COMMENT '虚拟支付沙箱 appkey 的 env 引用名', "
+                    + "virtual_pay_env TINYINT(1) DEFAULT 0 COMMENT '0=现网 1=沙箱', "
+                    + "virtual_pay_token_ref VARCHAR(128) COMMENT '虚拟支付消息推送 Token 的 env 引用名', "
+                    + "offer_id VARCHAR(32) COMMENT '虚拟支付应用 ID(每个小程序专属)', "
+                    + "alert_template_id VARCHAR(64) COMMENT '订阅消息模板 ID', "
+                    + "promotion_tier_template_id VARCHAR(64) COMMENT '促销档位模板 ID', "
+                    + "env_version VARCHAR(16) DEFAULT 'release' COMMENT '小程序码环境:dev/test/release', "
+                    + "wxacode_login_page VARCHAR(128) DEFAULT 'pages/login/scan-login', "
+                    + "enabled TINYINT(1) DEFAULT 1, "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX idx_app_id (app_id) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多小程序配置表(密钥只存 env 引用名,不存明文)'");
+            log.info("迁移465: miniapp_config 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移465: miniapp_config 建表失败(忽略,依赖 schema.sql 兜底): {}", e.getMessage());
+        }
+
+        // 播种 default 行:从 yml wechat: 读初始值,INSERT IGNORE 幂等
+        try {
+            int seeded = jdbcTemplate.update(
+                    "INSERT IGNORE INTO miniapp_config "
+                    + "(mp_code, display_name, app_id, secret_ref, mch_id, mch_key_ref, api_v3_key_ref, "
+                    + "mch_serial_no, private_key_path, notify_url, login_url, phone_url, test_mode, "
+                    + "fulfillment_enabled, virtual_pay_enabled, appkey_ref, sandbox_appkey_ref, "
+                    + "virtual_pay_env, virtual_pay_token_ref, offer_id, alert_template_id, "
+                    + "promotion_tier_template_id, env_version, wxacode_login_page, enabled) "
+                    + "VALUES ('default', 'CFC主小程序', ?, NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, "
+                    + "0, NULL, NULL, ?, NULL, ?, ?, ?, ?, ?, 1)",
+                    wechatAppid, wechatMchId, wechatMchSerialNo, wechatPrivateKeyPath, wechatNotifyUrl,
+                    wechatLoginUrl, wechatPhoneUrl, wechatTestMode ? 1 : 0, wechatFulfillmentEnabled ? 1 : 0,
+                    wechatVirtualPayEnv, wechatOfferId, wechatAlertTemplateId,
+                    wechatPromotionTierTemplateId, wechatEnvVersion, wechatWxacodeLoginPage);
+            log.info("迁移465: miniapp_config 播种 default 行结果={} (0=已存在跳过, 1=新插入)", seeded);
+        } catch (Exception e) {
+            log.warn("迁移465: miniapp_config 播种 default 行失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移466: 数据层多小程序隔离(T03)—— users/product_orders/package_orders 加 appid 列,
+     * 同时建立 (openid, appid) 唯一索引(决策 2:先查重后建;冲突只告警不阻塞)。
+     *
+     * <p>参考:docs/system_design.md §5、§6 P3、§7 T03。</p>
+     */
+    private void migrateMultiAppDataDimensions() {
+        // 1) 对 orders 加列
+        ensureColumn("product_orders", "appid", "VARCHAR(32) NULL COMMENT '小程序 appid(订单数据隔离维度)'");
+        ensureColumn("package_orders", "appid", "VARCHAR(32) NULL COMMENT '小程序 appid(套餐订单数据隔离维度)'");
+
+        // 2) users 加列并回填存量默认为当前 default appid(保持既有账号归属)
+        ensureColumn("users", "appid",
+                "VARCHAR(32) NULL COMMENT '小程序 appid(多小程序数据隔离维度,默认=default app)'");
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE users SET appid = ? WHERE appid IS NULL OR TRIM(appid)='' OR LENGTH(TRIM(appid))=0",
+                    defaultAppIdFallback != null ? defaultAppIdFallback.trim() : "wx5ba8038ef16fb245");
+            log.info("迁移466: users.appid 回填默认={},影响行数={}", defaultAppIdFallback, updated);
+        } catch (Exception e) {
+            log.warn("迁移466: users.appid 回填失败(可能列不存在或类型不兼容): {}", e.getMessage());
+        }
+
+        // 3) 查重:同 (openid, appid) 出现多条 → 告警(决策2:不阻塞)
+        try {
+            List<Map<String, Object>> dupRows = jdbcTemplate.queryForList(
+                    "SELECT openid, appid, COUNT(*) AS cnt FROM users "
+                    + "WHERE openid IS NOT NULL AND TRIM(openid) <> '' "
+                    + "GROUP BY openid, appid HAVING cnt > 1 LIMIT 20");
+            if (!dupRows.isEmpty()) {
+                log.warn("迁移466: users 表在 {} 个 (openid, appid) 组存在重复记录,将跳过唯一索引建表:"
+                        , dupRows.size());
+                for (Map<String, Object> row : dupRows) {
+                    log.warn("  - openid={}, appid={}, count={}",
+                            row.get("openid"), row.get("appid"), row.get("cnt"));
+                }
+                log.warn("请人工核查这些重复 openid 的归属后,再手动执行: ALTER TABLE users ADD UNIQUE INDEX uk_openid_appid(openid, appid)");
+            }
+        } catch (Exception e) {
+            // 若查重的查询因表结构/列缺失报错,忽略并继续(后续建唯一索引步骤会被 catch 捕获)
+            log.debug("迁移466: 查重查询失败(继续): {}", e.getMessage());
+        }
+
+        // 4) 尝试建唯一索引(IF NOT EXISTS 幂等;有冲突会抛 Duplicate key exception)
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD UNIQUE INDEX uk_openid_appid (openid, appid)");
+            log.info("迁移466: users.uk_openid_appid 唯一索引创建成功");
+        } catch (Exception e) {
+            String msg = e.getMessage();
+            if (msg != null && (msg.contains("Duplicate") || msg.contains("already exists")
+                    || msg.toLowerCase().contains("duplicate"))) {
+                log.warn("迁移466: users.uk_openid_appid 唯一索引建失败(存在同 appid 内重复 openid,请人工处理): {}", msg);
+            } else {
+                // 其他未知错误,仅 warn 保留应用启动能力;后续可重试迁移
+                log.warn("迁移466: users 唯一索引建失败(非幂等失败, 暂不重试): {}", msg);
+            }
+        }
     }
 
     /**

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java

@@ -48,10 +48,22 @@ public class JwtConfig {
     }
 
     public String generateToken(Long userId, List<String> roles) {
+        return generateToken(userId, roles, null);
+    }
+
+    /**
+     * 生成 token(多小程序:携带 mpCode claim,用于请求无 Header 时识别来源小程序)。
+     *
+     * @param mpCode 小程序路由标识;null/空则不写入 claim(老 token 兼容,回落 default app)
+     */
+    public String generateToken(Long userId, List<String> roles, String mpCode) {
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
         claims.put("role", roles.isEmpty() ? "" : roles.get(0));
         claims.put("roles", roles);
+        if (mpCode != null && !mpCode.trim().isEmpty()) {
+            claims.put("mpCode", mpCode.trim());
+        }
         return createToken(claims, userId.toString());
     }
 

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -1,5 +1,8 @@
 package com.etotem.cfc.config;
 
+import com.etotem.cfc.context.MiniAppContext;
+import com.etotem.cfc.context.MiniAppContextHolder;
+import com.etotem.cfc.context.MiniAppContextResolver;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
@@ -34,6 +37,9 @@ public class JwtInterceptor implements HandlerInterceptor {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private MiniAppContextResolver miniAppContextResolver;
+
 private static final String[] PUBLIC_PATHS = {
          "/api/auth/send-code",
          "/api/auth/phone-login",
@@ -211,6 +217,24 @@ private static final String[] PUBLIC_PATHS = {
             request.setAttribute("role", role);
             request.setAttribute("roles", roles);
 
+            // 多小程序兜底:JWT claim 中的 mpCode 作为 Header 之外的第二优先级来源
+            // (MiniAppInterceptor 已按 Header/路径/参数先解析;若其未显式指定,则用 JWT claim 覆盖)
+            try {
+                Object mpCodeClaim = claims.get("mpCode");
+                if (mpCodeClaim != null && StringUtils.hasText(mpCodeClaim.toString())) {
+                    boolean headerExplicit = Boolean.TRUE.equals(request.getAttribute("mpCodeExplicit"));
+                    if (!headerExplicit) {
+                        MiniAppContext mpCtx = miniAppContextResolver.resolve(request, claims);
+                        request.setAttribute("mpCode", mpCtx.getMpCode());
+                        request.setAttribute("appId", mpCtx.getAppId());
+                        request.setAttribute("mpCodeExplicit", Boolean.TRUE);
+                        MiniAppContextHolder.set(mpCtx);
+                    }
+                }
+            } catch (Exception e) {
+                log.debug("解析 JWT mpCode claim 失败(忽略, 保留 MiniAppInterceptor 已解析的上下文): {}", e.getMessage());
+            }
+
             // COUPON-P2 ③:把角色列表发布到 Spring Security 上下文,供 @PreAuthorize 方法级校验使用
             try {
                 List<GrantedAuthority> authorities = new ArrayList<>(roles.size());

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppInterceptor.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.context.MiniAppContext;
+import com.etotem.cfc.context.MiniAppContextHolder;
+import com.etotem.cfc.context.MiniAppContextResolver;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 多小程序上下文拦截器。
+ *
+ * <p>注册于 {@link JwtInterceptor} 之前(WebConfig 中先注册本拦截器),对
+ * {@code /api/**} 全路径生效(含公开路径与支付/虚拟支付回调,回调依赖 URL 的
+ * mpCode 段 + 签名校验防伪造)。</p>
+ *
+ * <p>职责:</p>
+ * <ol>
+ *   <li>按 {@link MiniAppContextResolver} 优先级链解析 mpCode(Header/JWT/路径/参数 → default);</li>
+ *   <li>写入 {@code request} attr(mpCode/appId/mpCodeExplicit)与 {@link MiniAppContextHolder};</li>
+ *   <li>显式传入未注册的 mpCode → 400 拒绝(防跨小程序串号);</li>
+ *   <li>{@code afterCompletion} 清理 ThreadLocal,与 JwtInterceptor 的
+ *       SecurityContext 清理策略对齐(防线程池复用串号)。</li>
+ * </ol>
+ *
+ * <p>JWT claim 的 mpCode 由 {@link JwtInterceptor} 在 Header 未显式指定时二次兜底
+ * 写入(本拦截器解析时尚未持有 claims)。</p>
+ */
+@Slf4j
+@Component
+public class MiniAppInterceptor implements HandlerInterceptor {
+
+    @Resource
+    private MiniAppContextResolver resolver;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        try {
+            MiniAppContext ctx = resolver.resolve(request, null);
+            request.setAttribute("mpCode", ctx.getMpCode());
+            request.setAttribute("appId", ctx.getAppId());
+            request.setAttribute("mpCodeExplicit", ctx.isExplicit());
+            MiniAppContextHolder.set(ctx);
+            return true;
+        } catch (IllegalArgumentException e) {
+            // 显式传入未知 mpCode → 拒绝,防串号
+            log.warn("MiniAppInterceptor 拒绝未知小程序标识: {}", e.getMessage());
+            writeBadRequest(response, e.getMessage());
+            return false;
+        } catch (Exception e) {
+            // 解析失败(如 DB 未就绪/配置缺失)→ 回落 default,不阻塞业务(保持既有行为)
+            log.warn("MiniAppInterceptor 解析小程序上下文失败, 回落 default: {}", e.getMessage());
+            MiniAppContext ctx = resolver.resolveDefault();
+            request.setAttribute("mpCode", ctx.getMpCode());
+            request.setAttribute("appId", ctx.getAppId());
+            request.setAttribute("mpCodeExplicit", false);
+            MiniAppContextHolder.set(ctx);
+            return true;
+        }
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        // 请求结束后清理,防止线程池复用泄漏上一请求的小程序上下文
+        MiniAppContextHolder.clear();
+    }
+
+    private void writeBadRequest(HttpServletResponse response, String message) throws java.io.IOException {
+        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+        response.setContentType("application/json;charset=UTF-8");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        String safe = message != null ? message.replace("\"", "'") : "未知小程序标识";
+        response.getWriter().write("{\"code\":400,\"message\":\"" + safe + "\",\"data\":null}");
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppProperties.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 多小程序静态兜底配置(yml {@code wechat:} 块)。
+ *
+ * <p>与 {@code miniapp_config} DB 行合并使用:DB 有值以 DB 为准,DB 无值回落 yml。
+ * 现阶段仅承载默认 app(<b>default</b>)的静态兜底,保证未携带
+ * {@code X-MP-Code} 的请求行为与改造前 100% 一致(零回归)。</p>
+ *
+ * <p>yml 示例:</p>
+ * <pre>
+ * wechat:
+ *   default-code: default
+ *   apps:
+ *     default:
+ *       app-id: wx5ba8038ef16fb245
+ *       display-name: CFC主小程序
+ * </pre>
+ *
+ * @see MiniAppStaticConfig
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "wechat")
+public class MiniAppProperties {
+
+    /** 默认小程序路由标识(未显式指定时一律回落该 app) */
+    private String defaultCode = "default";
+
+    /** 静态兜底 app 表:key=mpCode,value=静态配置 */
+    private Map<String, MiniAppStaticConfig> apps = new LinkedHashMap<>();
+
+    /**
+     * 获取默认 app 的静态兜底配置;yml 未配置时返回 null(此时依赖 DB 播种行或启动报错)。
+     */
+    public MiniAppStaticConfig getDefaultApp() {
+        return apps.get(defaultCode);
+    }
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/MiniAppStaticConfig.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.config;
+
+import lombok.Data;
+
+/**
+ * yml 静态兜底小程序配置({@code wechat.apps.<code>} 单个条目)。
+ *
+ * <p>非敏感路由类只承载 appId/displayName 两个静态维度;其余运行时配置
+ * (secret/env-version/模板 ID 等)均由 DB 行 {@code miniapp_config} 承载,
+ * 密钥类走 env 引用。</p>
+ */
+@Data
+public class MiniAppStaticConfig {
+
+    /** 微信 appid */
+    private String appId;
+
+    /** 展示名 */
+    private String displayName;
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -23,6 +23,9 @@ public class WebConfig implements WebMvcConfigurer {
 
     private static final Logger log = LoggerFactory.getLogger(WebConfig.class);
 
+    @Resource
+    private MiniAppInterceptor miniAppInterceptor;
+
     @Resource
     private JwtInterceptor jwtInterceptor;
 
@@ -92,6 +95,11 @@ public class WebConfig implements WebMvcConfigurer {
 
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
+        // 多小程序上下文拦截器:先于 JwtInterceptor 执行,按 Header/路径/参数解析 mpCode,
+        // 写入 request attr 与 MiniAppContextHolder(登录前也可识别;未携带一律 default)。
+        registry.addInterceptor(miniAppInterceptor)
+                .addPathPatterns("/api/**");
+
         registry.addInterceptor(jwtInterceptor)
                 .addPathPatterns("/api/**")
                 .excludePathPatterns(

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContext.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.context;
+
+import com.etotem.cfc.entity.MiniAppConfig;
+
+import java.io.Serializable;
+
+/**
+ * 当前请求的小程序上下文(不可变快照)。
+ *
+ * <p>承载请求路由标识 {@code mpCode}、微信 {@code appId} 与该小程序配置
+ * {@link MiniAppConfig}。由 {@link MiniAppContextResolver} 按优先级链解析、
+ * {@link MiniAppContextHolder}(ThreadLocal)在请求生命周期内持有。</p>
+ *
+ * <p>异步任务禁止跨线程裸读 ThreadLocal;如需透传,应显式捕获本对象快照。</p>
+ */
+public final class MiniAppContext implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 小程序路由标识(稳定短码),如 default/xiaoyi */
+    private final String mpCode;
+
+    /** 微信 appid */
+    private final String appId;
+
+    /** 该小程序配置(含密钥 env 引用名,不落明文) */
+    private final MiniAppConfig config;
+
+    /** true=从 Header/路径/参数/JWT 显式解析;false=default 兜底(未携带标识) */
+    private final boolean explicit;
+
+    public MiniAppContext(String mpCode, String appId, MiniAppConfig config, boolean explicit) {
+        this.mpCode = mpCode;
+        this.appId = appId;
+        this.config = config;
+        this.explicit = explicit;
+    }
+
+    public String getMpCode() {
+        return mpCode;
+    }
+
+    public String getAppId() {
+        return appId;
+    }
+
+    public MiniAppConfig getConfig() {
+        return config;
+    }
+
+    public boolean isExplicit() {
+        return explicit;
+    }
+
+    /** 解析后的路由标识(对齐类图 resolve() 语义) */
+    public String resolve() {
+        return mpCode;
+    }
+
+    @Override
+    public String toString() {
+        return "MiniAppContext{mpCode='" + mpCode + "', appId='" + appId + "', explicit=" + explicit + '}';
+    }
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContextHolder.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.context;
+
+import com.etotem.cfc.entity.MiniAppConfig;
+
+/**
+ * 请求级小程序上下文持有器(ThreadLocal)。
+ *
+ * <p>生命周期与 {@link com.etotem.cfc.config.MiniAppInterceptor} 对齐:
+ * {@code preHandle} 写入、{@code afterCompletion} 清理,避免线程池复用导致
+ * 上一请求的小程序上下文串号到下一请求。</p>
+ *
+ * <p>异步任务({@code @Async}/taskExecutor)禁止跨线程裸读;如需透传,
+ * 应在提交任务前捕获 {@link MiniAppContext} 快照并显式传入。</p>
+ */
+public final class MiniAppContextHolder {
+
+    private static final ThreadLocal<MiniAppContext> HOLDER = new ThreadLocal<>();
+
+    private MiniAppContextHolder() {
+    }
+
+    public static void set(MiniAppContext ctx) {
+        if (ctx == null) {
+            HOLDER.remove();
+        } else {
+            HOLDER.set(ctx);
+        }
+    }
+
+    /** 当前线程的小程序上下文;未设置时返回 null(调用方决定兜底策略) */
+    public static MiniAppContext get() {
+        return HOLDER.get();
+    }
+
+    /** 当前请求 mpCode;未设置时返回 null(默认 app 兜底在解析链完成) */
+    public static String mpCode() {
+        MiniAppContext ctx = HOLDER.get();
+        return ctx != null ? ctx.getMpCode() : null;
+    }
+
+    /** 当前请求 appId;未设置时返回 null */
+    public static String appId() {
+        MiniAppContext ctx = HOLDER.get();
+        return ctx != null ? ctx.getAppId() : null;
+    }
+
+    /** 当前请求配置;未设置时返回 null */
+    public static MiniAppConfig config() {
+        MiniAppContext ctx = HOLDER.get();
+        return ctx != null ? ctx.getConfig() : null;
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+}

+ 146 - 0
cfc-backend/src/main/java/com/etotem/cfc/context/MiniAppContextResolver.java

@@ -0,0 +1,146 @@
+package com.etotem.cfc.context;
+
+import com.etotem.cfc.entity.MiniAppConfig;
+import com.etotem.cfc.service.MiniAppConfigService;
+import io.jsonwebtoken.Claims;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 小程序上下文解析器(优先级链)。
+ *
+ * <p>解析优先级(首个非空命中即返回):</p>
+ * <ol>
+ *   <li>Header {@code X-MP-Code} —— 前端/回调 URL 显式传(登录前可用)</li>
+ *   <li>JWT claim {@code mpCode} —— 已登录业务请求兜底(Header 缺失时)</li>
+ *   <li>回调 URL 路径变量 —— {@code /v3-notify/{mpCode}}、{@code /virtual-pay/notify/{mpCode}}</li>
+ *   <li>请求参数 {@code mpCode} —— 极少数 GET 验证请求兜底</li>
+ *   <li>{@code default} —— 全部缺失时兜底,保持旧 app 行为</li>
+ * </ol>
+ *
+ * <p>显式传入但未在 {@code miniapp_config} 注册的 mpCode 抛
+ * {@link IllegalArgumentException}(由 {@link com.etotem.cfc.config.MiniAppInterceptor}
+ * 转 400 拒绝,防跨小程序串号)。</p>
+ */
+@Component
+public class MiniAppContextResolver {
+
+    /** 前端/回调显式传递小程序的 Header 名 */
+    public static final String HEADER_NAME = "X-MP-Code";
+
+    /** 请求参数名(极少数 GET 验证请求兜底) */
+    public static final String PARAM_NAME = "mpCode";
+
+    /** JWT claim 名 */
+    public static final String CLAIM_NAME = "mpCode";
+
+    private static final String V3_NOTIFY_PREFIX = "/v3-notify/";
+    private static final String VIRTUAL_PAY_PREFIX = "/virtual-pay/notify/";
+
+    @Resource
+    private MiniAppConfigService configService;
+
+    /**
+     * 按优先级链解析当前请求的小程序上下文。
+     *
+     * @param request 当前 HTTP 请求
+     * @param claims  已解析的 JWT claims;登录前或公开路径可为 null
+     * @return 解析后的小程序上下文(含 appId 与配置)
+     * @throws IllegalArgumentException mpCode 显式传入但未注册
+     */
+    public MiniAppContext resolve(HttpServletRequest request, Claims claims) {
+        String code = resolveMpCode(request, claims);
+        MiniAppConfig cfg = configService.getByCode(code);
+        if (cfg == null) {
+            throw new IllegalArgumentException("未知小程序标识: " + code);
+        }
+        boolean explicit = isExplicitMpCode(request, claims);
+        return new MiniAppContext(code, cfg.getAppId(), cfg, explicit);
+    }
+
+    /** 默认小程序上下文兜底(未携带任何标识时使用) */
+    public MiniAppContext resolveDefault() {
+        MiniAppConfig cfg = configService.getDefault();
+        return new MiniAppContext(cfg.getMpCode(), cfg.getAppId(), cfg, false);
+    }
+
+    /** 仅提取显式指定的 mpCode(不含 default 兜底),用于判断是否显式携带 */
+    public String extractExplicitMpCode(HttpServletRequest request, Claims claims) {
+        String code = resolveExplicitCode(request, claims);
+        return code;
+    }
+
+    private String resolveMpCode(HttpServletRequest request, Claims claims) {
+        String explicit = resolveExplicitCode(request, claims);
+        if (StringUtils.hasText(explicit)) {
+            return explicit;
+        }
+        return configService.getDefaultCode();
+    }
+
+    private String resolveExplicitCode(HttpServletRequest request, Claims claims) {
+        // 1. Header X-MP-Code
+        String header = request.getHeader(HEADER_NAME);
+        if (StringUtils.hasText(header)) {
+            return header.trim();
+        }
+        // 2. JWT claim mpCode
+        if (claims != null) {
+            Object claim = claims.get(CLAIM_NAME);
+            if (claim != null && StringUtils.hasText(claim.toString())) {
+                return claim.toString().trim();
+            }
+        }
+        // 3. 回调 URL 路径变量
+        String pathCode = extractPathMpCode(request.getRequestURI());
+        if (StringUtils.hasText(pathCode)) {
+            return pathCode;
+        }
+        // 4. 请求参数 mpCode
+        String param = request.getParameter(PARAM_NAME);
+        if (StringUtils.hasText(param)) {
+            return param.trim();
+        }
+        return null;
+    }
+
+    private boolean isExplicitMpCode(HttpServletRequest request, Claims claims) {
+        return StringUtils.hasText(resolveExplicitCode(request, claims));
+    }
+
+    /**
+     * 从回调 URL 路径提取 mpCode。
+     * 支持:{@code .../v3-notify/{mpCode}}、{@code .../virtual-pay/notify/{mpCode}}。
+     */
+    static String extractPathMpCode(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        String seg = null;
+        int idx = uri.indexOf(V3_NOTIFY_PREFIX);
+        if (idx >= 0) {
+            seg = uri.substring(idx + V3_NOTIFY_PREFIX.length());
+        } else {
+            idx = uri.indexOf(VIRTUAL_PAY_PREFIX);
+            if (idx >= 0) {
+                seg = uri.substring(idx + VIRTUAL_PAY_PREFIX.length());
+            }
+        }
+        if (seg == null) {
+            return null;
+        }
+        // 取首个路径段(排除 query 与多余段)
+        int end = seg.indexOf('/');
+        if (end >= 0) {
+            seg = seg.substring(0, end);
+        }
+        int query = seg.indexOf('?');
+        if (query >= 0) {
+            seg = seg.substring(0, query);
+        }
+        return seg.isEmpty() ? null : seg;
+    }
+}

+ 108 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MiniAppConfig.java

@@ -0,0 +1,108 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 多小程序配置实体(表 miniapp_config)。
+ *
+ * <p>安全约定:密钥/商户密钥等敏感项只存「env 引用名」(secretRef/mchKeyRef/apiV3KeyRef/
+ * appkeyRef/sandboxAppkeyRef/virtualPayTokenRef),明文一律不落库,运行时由
+ * {@code MiniAppConfigService.resolveXxx} 从环境变量解析。NULL 引用名 = 回落 yml
+ * {@code wechat.*} 默认 app 的既有取值(保持现状零回归)。</p>
+ *
+ * <p>参考:docs/system_design.md §2.1(表结构)、§2.2(Java 配置模型)。</p>
+ */
+@Data
+@TableName("miniapp_config")
+public class MiniAppConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 小程序路由标识(稳定短码,路由/回调/日志用),如 default/xiaoyi/zhishao */
+    private String mpCode;
+
+    /** 展示名,如 CFC主小程序 / AI小亿管理 */
+    private String displayName;
+
+    /** 微信 appid(区分配置的自然键) */
+    private String appId;
+
+    /** secret 的 env 引用名(如 WECHAT_SECRET_XIAOYI);NULL=回落 wechat.secret */
+    private String secretRef;
+
+    /** 微信支付商户号;NULL=不启用支付 */
+    private String mchId;
+
+    /** mch-key 的 env 引用名 */
+    private String mchKeyRef;
+
+    /** api-v3-key 的 env 引用名(支付回调 AES-256-GCM 解密用) */
+    private String apiV3KeyRef;
+
+    /** 商户 API 证书序列号 */
+    private String mchSerialNo;
+
+    /** apiclient_key.pem classpath 路径(每 appid 一份) */
+    private String privateKeyPath;
+
+    /** 支付回调 URL(可带 /{mpCode} 路径) */
+    private String notifyUrl;
+
+    /** 登录换 token 接口地址 */
+    private String loginUrl;
+
+    /** 手机号换取接口地址 */
+    private String phoneUrl;
+
+    /** 测试模式:1=跳过微信 API */
+    private Boolean testMode;
+
+    /** 管理端发货后自动同步微信物流开关 */
+    private Boolean fulfillmentEnabled;
+
+    /** 是否启用虚拟支付 */
+    private Boolean virtualPayEnabled;
+
+    /** 虚拟支付现网 appkey 的 env 引用名 */
+    private String appkeyRef;
+
+    /** 虚拟支付沙箱 appkey 的 env 引用名 */
+    private String sandboxAppkeyRef;
+
+    /** 虚拟支付环境:0=现网 1=沙箱 */
+    private Integer virtualPayEnv;
+
+    /** 虚拟支付消息推送 Token 的 env 引用名 */
+    private String virtualPayTokenRef;
+
+    /** 虚拟支付应用 ID(每个小程序专属) */
+    private String offerId;
+
+    /** 订阅消息模板 ID */
+    private String alertTemplateId;
+
+    /** 促销档位模板 ID */
+    private String promotionTierTemplateId;
+
+    /** 小程序码环境:dev/test/release */
+    private String envVersion;
+
+    /** 桌面端小程序码扫码登录落地页 */
+    private String wxacodeLoginPage;
+
+    /** 是否启用:1=启用 0=停用 */
+    private Boolean enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PackageOrder.java

@@ -19,6 +19,9 @@ public class PackageOrder implements Serializable {
 
     private Long userId;          // 购买者用户ID
 
+    /** 小程序 appid(订单数据隔离维度;下单时写入,默认=现 default app 的 appid) */
+    private String appid;
+
     private Long familyId;        // 家庭ID
 
     private Long packageId;       // 任务模板ID

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -21,6 +21,8 @@ public class ProductOrder implements Serializable {
     private String productType;
     private Long skuId;
     private Long buyerId;
+    /** 小程序 appid(订单数据隔离维度;下单时写入,默认=现 default app 的 appid) */
+    private String appid;
     private Long familyId;
     private Integer quantity;
     private Integer unitPrice;    // 单价(分)

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -22,6 +22,9 @@ public class User implements Serializable {
 
     private String openid;
 
+    /** 小程序 appid(多小程序数据隔离维度;默认=现 default app 的 appid) */
+    private String appid;
+
     private String unionid;
 
     private Long familyId;

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MiniAppMapper.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MiniAppConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 多小程序配置 Mapper(MyBatis-Plus 风格)。
+ *
+ * <p>业务侧禁止直接读写 miniapp_config;配置读取统一走
+ * {@code MiniAppConfigService}(内存缓存 + yml 兜底 + 密钥解析)。</p>
+ */
+@Mapper
+public interface MiniAppMapper extends BaseMapper<MiniAppConfig> {
+}

+ 251 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MiniAppConfigService.java

@@ -0,0 +1,251 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.config.MiniAppProperties;
+import com.etotem.cfc.config.MiniAppStaticConfig;
+import com.etotem.cfc.entity.MiniAppConfig;
+import com.etotem.cfc.mapper.MiniAppMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 多小程序配置服务:加载 / 缓存 / 热更 / 密钥解析。
+ *
+ * <p>参考:docs/system_design.md §2.2。设计要点:</p>
+ * <ul>
+ *   <li>内存缓存 {@code byCode}/{@code byAppId}(ConcurrentHashMap),
+ *       {@link #reload()} 在 {@code @PostConstruct} 与 DB 变更(管理端)后刷新;</li>
+ *   <li>DB 有值以 DB 为准,DB 无值回落 yml 静态兜底({@link MiniAppProperties}),
+ *       DB 与 yml 都无则 fail-fast 抛错;</li>
+ *   <li>密钥类(secret/mch-key/api-v3-key/appkey/virtual-pay-token)DB 只存 env 引用名,
+ *       运行时 {@code resolveXxx} 从环境变量/系统属性解析,缺失即抛
+ *       {@link IllegalStateException}(绝不静默回落,避免用错 app 密钥);</li>
+ *   <li>首启时 DB 表可能尚未由迁移建出(迁移在 CommandLineRunner 执行),
+ *       {@link #reload()} 对读表失败做降级处理(回落 yml),DatabaseInitializer
+ *       迁移完成后会再次调用 {@link #reload()} 补齐。</li>
+ * </ul>
+ */
+@Slf4j
+@Service
+public class MiniAppConfigService {
+
+    @Resource
+    private MiniAppMapper miniAppMapper;
+
+    @Resource
+    private MiniAppProperties miniAppProperties;
+
+    // ===== default app 兜底明文(yml wechat.* 解析后取值;DB 引用名为 NULL 时回落)=====
+    @Value("${wechat.secret:}")
+    private String defaultSecret;
+
+    @Value("${wechat.mch-key:}")
+    private String defaultMchKey;
+
+    @Value("${wechat.api-v3-key:}")
+    private String defaultApiV3Key;
+
+    @Value("${wechat.appkey:}")
+    private String defaultAppkey;
+
+    @Value("${wechat.sandbox-appkey:}")
+    private String defaultSandboxAppkey;
+
+    @Value("${wechat.virtual-pay-token:}")
+    private String defaultVirtualPayToken;
+
+    private final ConcurrentHashMap<String, MiniAppConfig> byCode = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, MiniAppConfig> byAppId = new ConcurrentHashMap<>();
+
+    private volatile long lastReloadAt = 0L;
+
+    @PostConstruct
+    public void init() {
+        reload();
+    }
+
+    /**
+     * 从 DB 全量重载配置到内存缓存。DB 不可达/表未建时降级为 yml 兜底,不阻塞启动。
+     * 迁移完成后(DatabaseInitializer)应再次调用以补齐 DB 行。
+     */
+    public synchronized void reload() {
+        try {
+            List<MiniAppConfig> all = miniAppMapper.selectList(null);
+            Map<String, MiniAppConfig> newByCode = new ConcurrentHashMap<>();
+            Map<String, MiniAppConfig> newByAppId = new ConcurrentHashMap<>();
+            for (MiniAppConfig c : all) {
+                if (c == null) {
+                    continue;
+                }
+                if (c.getMpCode() != null) {
+                    newByCode.put(c.getMpCode(), c);
+                }
+                if (c.getAppId() != null) {
+                    newByAppId.put(c.getAppId(), c);
+                }
+            }
+            byCode.clear();
+            byCode.putAll(newByCode);
+            byAppId.clear();
+            byAppId.putAll(newByAppId);
+            lastReloadAt = System.currentTimeMillis();
+            log.info("MiniAppConfigService.reload(): 加载 {} 个小程序配置", all.size());
+        } catch (Exception e) {
+            // 首启时 miniapp_config 表尚未由迁移建出、或 DB 短暂不可达:
+            // 回落 yml 静态兜底(default 可用),不阻塞启动。
+            log.warn("MiniAppConfigService.reload() 读取 miniapp_config 失败(回落 yml 兜底): {}", e.getMessage());
+            lastReloadAt = System.currentTimeMillis();
+        }
+    }
+
+    /**
+     * 按 mpCode 取配置;null/空 → 默认 app;未知 code → null(调用方决定拒绝/兜底)。
+     */
+    public MiniAppConfig getByCode(String mpCode) {
+        if (mpCode == null || mpCode.trim().isEmpty()) {
+            return getDefault();
+        }
+        MiniAppConfig c = byCode.get(mpCode);
+        if (c != null) {
+            return c;
+        }
+        if (mpCode.equals(getDefaultCode())) {
+            return getDefault();
+        }
+        return null;
+    }
+
+    /** 按 appId 取配置;不存在返回 null */
+    public MiniAppConfig getByAppId(String appId) {
+        if (appId == null) {
+            return null;
+        }
+        MiniAppConfig c = byAppId.get(appId);
+        if (c != null) {
+            return c;
+        }
+        if (appId.equals(getDefaultAppId())) {
+            return getDefault();
+        }
+        return null;
+    }
+
+    /** 默认 app 配置:DB 播种行优先,缺失回落 yml 静态兜底 */
+    public MiniAppConfig getDefault() {
+        MiniAppConfig c = byCode.get(getDefaultCode());
+        if (c != null) {
+            return c;
+        }
+        return ymlFallbackConfig();
+    }
+
+    /** 默认 app 的 appId(yml apps.default.app-id) */
+    public String getDefaultAppId() {
+        MiniAppStaticConfig s = miniAppProperties.getDefaultApp();
+        return s != null ? s.getAppId() : null;
+    }
+
+    /** 默认 mpCode(yml wechat.default-code,缺省 default) */
+    public String getDefaultCode() {
+        String code = miniAppProperties.getDefaultCode();
+        return code != null && !code.trim().isEmpty() ? code.trim() : "default";
+    }
+
+    /** 是否默认 app(null/空/default 均视为默认) */
+    public boolean isDefaultCode(String mpCode) {
+        return mpCode == null || mpCode.trim().isEmpty() || getDefaultCode().equals(mpCode);
+    }
+
+    // ===== 密钥解析(fail-fast:引用名存在但 env 未配置 → 抛错)=====
+
+    /** 解析 secret 明文;secretRef 为 NULL 时回落 yml wechat.secret */
+    public String resolveSecret(MiniAppConfig c) {
+        requireConfig(c);
+        return resolveEnvRef(c.getSecretRef(), defaultSecret, "wechat.secret");
+    }
+
+    /** 解析 mch-key 明文;mchKeyRef 为 NULL 时回落 yml wechat.mch-key */
+    public String resolveMchKey(MiniAppConfig c) {
+        requireConfig(c);
+        return resolveEnvRef(c.getMchKeyRef(), defaultMchKey, "wechat.mch-key");
+    }
+
+    /** 解析 api-v3-key 明文(支付回调 AES-256-GCM 解密用) */
+    public String resolveApiV3Key(MiniAppConfig c) {
+        requireConfig(c);
+        return resolveEnvRef(c.getApiV3KeyRef(), defaultApiV3Key, "wechat.api-v3-key");
+    }
+
+    /** 解析虚拟支付消息推送 Token 明文 */
+    public String resolveVirtualPayToken(MiniAppConfig c) {
+        requireConfig(c);
+        return resolveEnvRef(c.getVirtualPayTokenRef(), defaultVirtualPayToken, "wechat.virtual-pay-token");
+    }
+
+    /** 解析虚拟支付 appkey 明文(现网/沙箱由 sandbox 决定) */
+    public String resolveAppkey(MiniAppConfig c, boolean sandbox) {
+        requireConfig(c);
+        String ref = sandbox ? c.getSandboxAppkeyRef() : c.getAppkeyRef();
+        return resolveEnvRef(ref, sandbox ? defaultSandboxAppkey : defaultAppkey,
+                sandbox ? "wechat.sandbox-appkey" : "wechat.appkey");
+    }
+
+    private String resolveEnvRef(String ref, String fallback, String propName) {
+        if (ref == null || ref.trim().isEmpty()) {
+            return fallback;
+        }
+        String val = System.getenv(ref);
+        if (val == null || val.isEmpty()) {
+            val = System.getProperty(ref);
+        }
+        if (val == null || val.isEmpty()) {
+            // fail-fast:绝不静默回落,避免用错 app 密钥
+            throw new IllegalStateException("密钥未配置: " + ref + "(请设置环境变量或系统属性 " + ref + ",默认属性源: " + propName + ")");
+        }
+        return val;
+    }
+
+    private void requireConfig(MiniAppConfig c) {
+        if (c == null) {
+            throw new IllegalArgumentException("MiniAppConfig 为空,无法解析密钥");
+        }
+    }
+
+    /** 最后成功/尝试 reload 的时间戳(毫秒) */
+    public long getLastReloadAt() {
+        return lastReloadAt;
+    }
+
+    /** 当前缓存的小程序数 */
+    public int cachedCount() {
+        return byCode.size();
+    }
+
+    private MiniAppConfig ymlFallbackConfig() {
+        MiniAppStaticConfig s = miniAppProperties.getDefaultApp();
+        if (s == null || s.getAppId() == null || s.getAppId().trim().isEmpty()) {
+            throw new IllegalStateException("默认小程序配置缺失:miniapp_config 无 default 行且 yml wechat.apps."
+                    + getDefaultCode() + ".app-id 未配置");
+        }
+        MiniAppConfig c = new MiniAppConfig();
+        c.setMpCode(getDefaultCode());
+        c.setDisplayName(s.getDisplayName());
+        c.setAppId(s.getAppId().trim());
+        c.setTestMode(false);
+        c.setFulfillmentEnabled(true);
+        c.setVirtualPayEnabled(false);
+        c.setVirtualPayEnv(0);
+        c.setEnabled(true);
+        c.setLoginUrl("https://api.weixin.qq.com/sns/jscode2session");
+        c.setPhoneUrl("https://api.weixin.qq.com/wxa/business/getuserphonenumber");
+        c.setEnvVersion("release");
+        c.setWxacodeLoginPage("pages/login/scan-login");
+        return c;
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.context.MiniAppContextHolder;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.ProductOrderService;
 import com.etotem.cfc.service.api.PaymentServiceInterface;
@@ -57,6 +58,9 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     private ProductOrderMapper productOrderMapper;
 
+    @Resource
+    private MiniAppConfigService miniAppConfigService;
+
     @Resource
     private CommissionService commissionService;
 
@@ -150,8 +154,11 @@ public class PaymentService implements PaymentServiceInterface {
                 order.setOrderNo(orderNo);
                 order.setPrice(0);
                 order.setStatus("pending");
+                order.setAppid(resolveAppId());
                 order.setCreatedAt(new Date());
                 order.setUpdatedAt(new Date());
+            } else if (order.getAppid() == null || order.getAppid().isEmpty()) {
+                order.setAppid(resolveAppId());
             }
             order.setContactName(name);
             order.setContactPhone(phone);
@@ -165,6 +172,7 @@ public class PaymentService implements PaymentServiceInterface {
             try {
                 ProductOrder po = new ProductOrder();
                 po.setBuyerId(userId);
+                po.setAppid(resolveAppId());
                 po.setOrderNo(orderNo);
                 po.setProductId(0L);
                 po.setProductName("肠道菌群基因检测");
@@ -187,6 +195,14 @@ public class PaymentService implements PaymentServiceInterface {
         }
     }
 
+    /**
+     * 解析当前请求所属小程序 appid(上下文缺失时回落到 default app)。
+     */
+    private String resolveAppId() {
+        String appId = MiniAppContextHolder.appId();
+        return appId != null ? appId : miniAppConfigService.getDefaultAppId();
+    }
+
     @Override
     public Map<String, Object> createWechatNativePayOrder(String orderNo, String description, Integer amount) {
         try {

+ 31 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -37,6 +37,7 @@ import com.etotem.cfc.mapper.ConsigneeMapper;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.UserUtil;
 import com.etotem.cfc.common.RolesUtil;
+import com.etotem.cfc.context.MiniAppContextHolder;
 
 
 @Slf4j
@@ -92,6 +93,9 @@ private FamilyInvitationService familyInvitationService;
     @Resource
     private RealNameBackfillService realNameBackfillService;
 
+    @Resource
+    private MiniAppConfigService miniAppConfigService;
+
     public LoginResultDTO wechatLogin(WechatLoginDTO dto) {
         // 1. 通过code获取openid
         Map<String, String> sessionData = wechatService.code2Session(dto.getCode());
@@ -107,9 +111,15 @@ private FamilyInvitationService familyInvitationService;
             phone = wechatService.getPhoneNumber(dto.getPhoneCode());
         }
 
-        // 3. 先通过openid查找用户
+        // 3. 多小程序隔离:按 (openid, appid) 查找用户(default app 亦带 appid,迁移466已回填)
+        String mpCode = MiniAppContextHolder.mpCode();
+        String appId = MiniAppContextHolder.appId();
+        if (appId == null) {
+            appId = miniAppConfigService.getDefaultAppId();
+        }
         User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
-                .eq(User::getOpenid, openid));
+                .eq(User::getOpenid, openid)
+                .eq(User::getAppid, appId));
 
         boolean isNewUser = false;
 
@@ -126,6 +136,7 @@ private FamilyInvitationService familyInvitationService;
 
                 user = new User();
                 user.setOpenid(openid);
+                user.setAppid(appId);
                 user.setUnionid(unionid != null ? unionid : "");
                 user.setRole("parent");
                 user.setNickname(dto.getNickname() != null ? dto.getNickname() : (phone != null ? "用户" + phone.substring(7) : "微信用户"));
@@ -138,8 +149,9 @@ private FamilyInvitationService familyInvitationService;
                 createUserAndFamily(user);
                 try { onboardingService.initForUser(user.getId()); onboardingService.completeTask(user.getId(), "REGISTER"); } catch (Exception e) { /* onboarding init failure should not block login */ }
             } else {
-                // 6. 手机号已存在但openid不同,绑定openid
+                // 6. 手机号已存在但openid不同,绑定openid(同步写入当前小程序 appid)
                 user.setOpenid(openid);
+                user.setAppid(appId);
                 user.setUnionid(unionid != null ? unionid : "");
                 user.setSessionKey(sessionData.get("session_key"));
                 user.setUpdatedAt(new Date());
@@ -165,8 +177,8 @@ private FamilyInvitationService familyInvitationService;
             log.info("✅ 普通用户 {} (userId={}, role={}) 登录成功", user.getNickname(), user.getId(), user.getRole());
         }
         
-        // 生成 token
-        String token = jwtConfig.generateToken(user.getId(), RolesUtil.effectiveRoles(user));
+        // 生成 token(携带 mpCode claim,供多小程序上下文恢复)
+        String token = jwtConfig.generateToken(user.getId(), RolesUtil.effectiveRoles(user), mpCode);
 
         LoginResultDTO result = new LoginResultDTO();
         result.setToken(token);
@@ -284,9 +296,15 @@ private FamilyInvitationService familyInvitationService;
         String openid = sessionData.get("openid");
         String unionid = sessionData.get("unionid");
 
-        // 2. 查找用户
+        // 2. 查找用户(按 (openid, appid) 隔离;同 openid 不同小程序互不可见)
+        String mpCode = MiniAppContextHolder.mpCode();
+        String appId = MiniAppContextHolder.appId();
+        if (appId == null) {
+            appId = miniAppConfigService.getDefaultAppId();
+        }
         User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
-                .eq(User::getOpenid, openid));
+                .eq(User::getOpenid, openid)
+                .eq(User::getAppid, appId));
 
         if (user == null) {
             throw new RuntimeException("用户不存在,请先登录");
@@ -297,14 +315,17 @@ private FamilyInvitationService familyInvitationService;
             throw new RuntimeException("该账号不允许登录小程序");
         }
 
-        // 持久化最新 sessionKey(虚拟支付 signature 签名用)
+        // 持久化最新 sessionKey(虚拟支付 signature 签名用);app id 兜底写入(历史 null 行)
         user.setSessionKey(sessionData.get("session_key"));
+        if (user.getAppid() == null || user.getAppid().isEmpty()) {
+            user.setAppid(appId);
+        }
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         ensureUserHasFamily(user);
 
-        // 4. 生成token
-        String token = jwtConfig.generateToken(user.getId(), RolesUtil.effectiveRoles(user));
+        // 4. 生成token(携带 mpCode claim)
+        String token = jwtConfig.generateToken(user.getId(), RolesUtil.effectiveRoles(user), mpCode);
 
         LoginResultDTO result = new LoginResultDTO();
         result.setToken(token);

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -97,6 +97,12 @@ public class WechatService implements WechatServiceInterface {
     @Resource
     private RedisTemplate<String, Object> redisTemplate;
 
+    @Resource
+    private MiniAppConfigService miniAppConfigService;
+
+    @Resource
+    private WxApiClient wxApiClient;
+
     /** Redis access_token 缓存 key 与 TTL(秒) */
     private static final String STABLE_TOKEN_KEY = "wechat:stable_access_token";
     private static final long STABLE_TOKEN_TTL_SECONDS = 7000;
@@ -273,6 +279,22 @@ public class WechatService implements WechatServiceInterface {
         return getStableAccessToken();
     }
 
+    /**
+     * 按小程序路由标识获取 access_token(多小程序 appid 维度隔离缓存)。
+     *
+     * <p>default app 沿用既有逻辑与旧 Redis key {@code wechat:stable_access_token}
+     * (兼容存量 token);非 default app 走 {@link WxApiClient},Redis key 为
+     * {@code wechat:stable_token:{appId}},互不覆盖。</p>
+     *
+     * @param mpCode 小程序路由标识;null/空/default 一律走默认 app
+     */
+    public String getAccessToken(String mpCode) {
+        if (miniAppConfigService.isDefaultCode(mpCode)) {
+            return getAccessToken();
+        }
+        return wxApiClient.getAccessToken(mpCode);
+    }
+
     /**
      * 获取stable access_token(带缓存)
      * 使用 /cgi-bin/stable_token 接口,token有效期7200秒,可被所有实例共享使用
@@ -330,6 +352,18 @@ public class WechatService implements WechatServiceInterface {
         }
     }
 
+    /**
+     * 按小程序路由标识清除 access_token 缓存(微信返回 42001/40001 时调用)。
+     * default app 走 {@link #invalidateAccessToken()};非 default 走 {@link WxApiClient}。
+     */
+    public void invalidateAccessToken(String mpCode) {
+        if (miniAppConfigService.isDefaultCode(mpCode)) {
+            invalidateAccessToken();
+            return;
+        }
+        wxApiClient.invalidate(mpCode);
+    }
+
     /**
      * 获取常规 access_token(fallback:当 stable_token 返回的 token 已失效时调用)
      * /cgi-bin/token 每次返回全新 token,保证 img_sec_check 等接口可用

+ 201 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WxApiClient.java

@@ -0,0 +1,201 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.MiniAppConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 微信小程序 API 客户端(per-appid access_token 管理器)。
+ *
+ * <p>参考:docs/system_design.md §2.3、§4.5。为每个 appid 独立缓存
+ * stable_access_token,彻底解决多小程序串号:</p>
+ * <ul>
+ *   <li>Redis key:{@code wechat:stable_token:{appId}}(多实例共享);</li>
+ *   <li>进程内缓存:{@code ConcurrentHashMap<appId, TokenEntry>},优先读取;</li>
+ *   <li>提前刷新:命中但剩余 TTL &lt; 200s 时后台异步预刷新(stale-while-revalidate),
+ *       避免临界阻塞;</li>
+ *   <li>{@link #invalidate(String)} 同步删除对应 Redis key 与进程内 entry(回调 42001/40001 触发)。</li>
+ * </ul>
+ *
+ * <p>default app 的 token 缓存兼容保留旧 Redis key
+ * {@code wechat:stable_access_token}(由 {@link WechatService} 既有逻辑承担),
+ * 本组件仅服务非 default app(避免改动默认行为)。</p>
+ */
+@Slf4j
+@Component
+public class WxApiClient {
+
+    /** 非 default app 的 Redis access_token 缓存 key 前缀(key = 前缀 + appId) */
+    private static final String STABLE_TOKEN_KEY_PREFIX = "wechat:stable_token:";
+
+    /** 进程内/Redis token TTL(秒),与 WechatService 既有 7000s 一致 */
+    private static final long STABLE_TOKEN_TTL_SECONDS = 7000;
+
+    /** 提前刷新阈值(毫秒):命中但剩余 TTL 低于该值则后台异步预刷新 */
+    private static final long EARLY_REFRESH_MS = 200_000L;
+
+    private static final String STABLE_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/stable_token";
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Resource
+    private MiniAppConfigService configService;
+
+    private final RestTemplate restTemplate = createRestTemplate();
+
+    private final ConcurrentHashMap<String, TokenEntry> cache = new ConcurrentHashMap<>();
+
+    private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
+
+    private static RestTemplate createRestTemplate() {
+        org.springframework.http.client.SimpleClientHttpRequestFactory factory =
+                new org.springframework.http.client.SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(3000);
+        factory.setReadTimeout(5000);
+        return new RestTemplate(factory);
+    }
+
+    /** 按 mpCode 取 access_token(非 default app 使用);未知 mpCode 抛 IllegalArgumentException */
+    public String getAccessToken(String mpCode) {
+        MiniAppConfig cfg = configService.getByCode(mpCode);
+        if (cfg == null) {
+            throw new IllegalArgumentException("未知小程序标识: " + mpCode);
+        }
+        return getAccessToken(cfg);
+    }
+
+    /** 按配置取 access_token(appid 维度隔离缓存) */
+    public String getAccessToken(MiniAppConfig cfg) {
+        if (cfg == null || cfg.getAppId() == null || cfg.getAppId().isEmpty()) {
+            throw new IllegalArgumentException("MiniAppConfig 缺失 appId,无法获取 access_token");
+        }
+        String appId = cfg.getAppId();
+        long now = System.currentTimeMillis();
+
+        TokenEntry entry = cache.get(appId);
+        if (entry != null && entry.expireAt > now) {
+            // 提前刷新:剩余 TTL < 200s 时后台异步预刷新,返回现有 token(stale-while-revalidate)
+            if (entry.expireAt - now < EARLY_REFRESH_MS) {
+                asyncRefresh(cfg, appId);
+            }
+            return entry.token;
+        }
+
+        Object lock = locks.computeIfAbsent(appId, k -> new Object());
+        synchronized (lock) {
+            entry = cache.get(appId);
+            if (entry != null && entry.expireAt > now) {
+                if (entry.expireAt - now < EARLY_REFRESH_MS) {
+                    asyncRefresh(cfg, appId);
+                }
+                return entry.token;
+            }
+            // 内存未命中:优先从 Redis 读取(多实例共享同一 token)
+            try {
+                Object cached = redisTemplate.opsForValue().get(redisKey(appId));
+                if (cached != null) {
+                    String token = cached.toString();
+                    cache.put(appId, new TokenEntry(token, now + STABLE_TOKEN_TTL_SECONDS * 1000));
+                    return token;
+                }
+            } catch (Exception e) {
+                log.warn("[appId={}] Redis 读取 access_token 失败,走微信接口: {}", appId, e.getMessage());
+            }
+            String token = doGetStableToken(cfg);
+            cache.put(appId, new TokenEntry(token, now + STABLE_TOKEN_TTL_SECONDS * 1000));
+            try {
+                redisTemplate.opsForValue().set(redisKey(appId), token, Duration.ofSeconds(STABLE_TOKEN_TTL_SECONDS));
+            } catch (Exception e) {
+                log.warn("[appId={}] Redis 写入 access_token 失败(不影响本次请求): {}", appId, e.getMessage());
+            }
+            return token;
+        }
+    }
+
+    /** 清除指定 mpCode 的 access_token 缓存(微信返回 42001/40001 时调用) */
+    public void invalidate(String mpCode) {
+        MiniAppConfig cfg = configService.getByCode(mpCode);
+        if (cfg == null) {
+            return;
+        }
+        String appId = cfg.getAppId();
+        cache.remove(appId);
+        try {
+            redisTemplate.delete(redisKey(appId));
+        } catch (Exception e) {
+            log.warn("[appId={}] Redis 删除 access_token 失败(忽略): {}", appId, e.getMessage());
+        }
+    }
+
+    private String doGetStableToken(MiniAppConfig cfg) {
+        try {
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("grant_type", "client_credential");
+            requestBody.put("appid", cfg.getAppId());
+            requestBody.put("secret", configService.resolveSecret(cfg));
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(requestBody.toJSONString(), headers);
+
+            ResponseEntity<String> response = restTemplate.exchange(STABLE_TOKEN_URL, HttpMethod.POST, entity, String.class);
+            JSONObject json = JSON.parseObject(response.getBody());
+            if (json.containsKey("access_token")) {
+                return json.getString("access_token");
+            }
+            log.error("[appId={}] 获取 stable access_token 失败: {}", cfg.getAppId(), json.toJSONString());
+            throw new RuntimeException("获取access_token失败");
+        } catch (Exception e) {
+            log.error("[appId={}] 获取 stable access_token 异常", cfg.getAppId(), e);
+            throw new RuntimeException("获取access_token异常");
+        }
+    }
+
+    /** 后台异步预刷新(stale-while-revalidate),失败不影响当前请求 */
+    private void asyncRefresh(MiniAppConfig cfg, String appId) {
+        CompletableFuture.runAsync(() -> {
+            try {
+                String token = doGetStableToken(cfg);
+                long now = System.currentTimeMillis();
+                cache.put(appId, new TokenEntry(token, now + STABLE_TOKEN_TTL_SECONDS * 1000));
+                try {
+                    redisTemplate.opsForValue().set(redisKey(appId), token, Duration.ofSeconds(STABLE_TOKEN_TTL_SECONDS));
+                } catch (Exception e) {
+                    log.warn("[appId={}] 异步刷新 Redis 写入失败: {}", appId, e.getMessage());
+                }
+            } catch (Exception e) {
+                log.warn("[appId={}] 异步刷新 access_token 失败(忽略,下次同步刷新兜底): {}", appId, e.getMessage());
+            }
+        });
+    }
+
+    private String redisKey(String appId) {
+        return STABLE_TOKEN_KEY_PREFIX + appId;
+    }
+
+    /** 进程内 token 条目 */
+    private static final class TokenEntry {
+        final String token;
+        final long expireAt;
+
+        TokenEntry(String token, long expireAt) {
+            this.token = token;
+            this.expireAt = expireAt;
+        }
+    }
+}

+ 172 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WxPayClient.java

@@ -0,0 +1,172 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.MiniAppConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 微信支付 v3 客户端(per-appid 凭证/签名组件)。
+ *
+ * <p>参考:docs/system_design.md §4.5。按 appid 惰性加载商户 API 私钥
+ * (每 appid 一份 {@code cert/{mpCode}/apiclient_key.pem}),并提供:</p>
+ * <ul>
+ *   <li>{@link #getPrivateKey(MiniAppConfig)} —— 私钥加载 + 进程内缓存;</li>
+ *   <li>{@link #decryptAes256Gcm(...)} —— 支付回调资源解密(AES-256-GCM,apiV3Key 按 app);</li>
+ *   <li>{@link #buildAuthorizationHeader(...)} —— 商户请求 v3 签名头(method/timestamp/nonce/body);</li>
+ *   <li>{@link #verifySignature(...)} —— 微信平台签名校验(平台公钥由调用方注入,T04 支付回调验签补齐时接线)。</li>
+ * </ul>
+ *
+ * <p>密钥明文仅在内存短暂存在,禁止打日志。</p>
+ */
+@Slf4j
+@Component
+public class WxPayClient {
+
+    private static final String RSA_SHA256 = "SHA256withRSA";
+    private static final String AES_GCM = "AES/GCM/NoPadding";
+    private static final String AES_KEY = "AES";
+    private static final int GCM_TAG_BITS = 128;
+
+    private final ConcurrentHashMap<String, PrivateKey> privateKeyCache = new ConcurrentHashMap<>();
+
+    @Resource
+    private MiniAppConfigService configService;
+
+    /**
+     * 获取该小程序配置的商户 API 私钥(惰性加载 + 缓存)。无法加载时抛 IllegalStateException。
+     */
+    public PrivateKey getPrivateKey(MiniAppConfig cfg) {
+        if (cfg == null) {
+            throw new IllegalArgumentException("MiniAppConfig 为空");
+        }
+        PrivateKey cached = privateKeyCache.get(cfg.getAppId());
+        if (cached != null) {
+            return cached;
+        }
+        synchronized (privateKeyCache) {
+            cached = privateKeyCache.get(cfg.getAppId());
+            if (cached != null) {
+                return cached;
+            }
+            PrivateKey key = loadPrivateKey(cfg);
+            privateKeyCache.put(cfg.getAppId(), key);
+            return key;
+        }
+    }
+
+    private PrivateKey loadPrivateKey(MiniAppConfig cfg) {
+        String path = cfg.getPrivateKeyPath();
+        if (path == null || path.trim().isEmpty()) {
+            throw new IllegalStateException("[appId=" + cfg.getAppId() + "] private-key-path 未配置,无法加载商户 API 私钥");
+        }
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream(path.replace("\\", "/"))) {
+            if (is == null) {
+                throw new java.io.FileNotFoundException("私钥文件未在classpath中找到: " + path);
+            }
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
+                String keyContent = reader.lines()
+                        .filter(line -> !line.startsWith("-----"))
+                        .collect(java.util.stream.Collectors.joining());
+                byte[] keyBytes = Base64.getDecoder().decode(keyContent);
+                PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+                KeyFactory kf = KeyFactory.getInstance("RSA");
+                return kf.generatePrivate(spec);
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException("[appId=" + cfg.getAppId() + "] 加载微信支付私钥失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 支付回调资源解密(微信支付 v3 回调报文 decrypt 字段)。
+     * 使用该 app 的 api-v3-key 做 AES-256-GCM 解密。
+     */
+    public String decryptAes256Gcm(MiniAppConfig cfg, String ciphertext, String aad, String nonce) {
+        try {
+            String apiV3Key = configService.resolveApiV3Key(cfg);
+            byte[] keyBytes = apiV3Key.getBytes(StandardCharsets.UTF_8);
+            if (keyBytes.length != 32) {
+                throw new IllegalStateException("[appId=" + cfg.getAppId() + "] api-v3-key 长度必须为 32 字节");
+            }
+            byte[] cipherBytes = Base64.getDecoder().decode(ciphertext);
+            Cipher cipher = Cipher.getInstance(AES_GCM);
+            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_BITS, nonce.getBytes(StandardCharsets.UTF_8));
+            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, AES_KEY), spec);
+            cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+            byte[] plain = cipher.doFinal(cipherBytes);
+            return new String(plain, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new IllegalStateException("[appId=" + cfg.getAppId() + "] 支付回调资源解密失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 构造微信支付 v3 请求 Authorization 头(商户签名)。
+     * 签名串:{method}\n{urlPath}\n{timestamp}\n{nonce}\n{body}\n,用该 app 商户私钥做 RSA-SHA256。
+     */
+    public String buildAuthorizationHeader(MiniAppConfig cfg, String method, String urlPath,
+                                           String body, String timestamp, String nonce) {
+        String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n";
+        try {
+            Signature signature = Signature.getInstance(RSA_SHA256);
+            signature.initSign(getPrivateKey(cfg));
+            signature.update(message.getBytes(StandardCharsets.UTF_8));
+            String sign = Base64.getEncoder().encodeToString(signature.sign());
+            return "WECHATPAY2-SHA256-RSA2048 mchid=\"" + cfg.getMchId()
+                    + "\",nonce_str=\"" + nonce
+                    + "\",signature=\"" + sign
+                    + "\",timestamp=\"" + timestamp
+                    + "\",serial_no=\"" + cfg.getMchSerialNo() + "\"";
+        } catch (Exception e) {
+            throw new IllegalStateException("[appId=" + cfg.getAppId() + "] 生成微信支付 v3 签名失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 校验微信支付平台签名(回调验签,T04 接线:平台公钥来自微信支付平台证书)。
+     * 验签串与请求签名串相同:{method}\n{urlPath}\n{timestamp}\n{nonce}\n{body}\n。
+     */
+    public boolean verifySignature(PublicKey platformPublicKey, String method, String urlPath,
+                                   String timestamp, String nonce, String body, String signature) {
+        if (platformPublicKey == null || signature == null || signature.isEmpty()) {
+            return false;
+        }
+        try {
+            String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n";
+            Signature verifier = Signature.getInstance(RSA_SHA256);
+            verifier.initVerify(platformPublicKey);
+            verifier.update(message.getBytes(StandardCharsets.UTF_8));
+            return verifier.verify(Base64.getDecoder().decode(signature));
+        } catch (Exception e) {
+            log.warn("微信支付平台签名校验异常: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /** 由 X.509 公钥字节构造 RSA PublicKey(平台证书解析用) */
+    public PublicKey buildPublicKey(byte[] x509Der) {
+        try {
+            X509EncodedKeySpec spec = new X509EncodedKeySpec(x509Der);
+            return KeyFactory.getInstance("RSA").generatePublic(spec);
+        } catch (Exception e) {
+            throw new IllegalStateException("构造微信支付平台公钥失败: " + e.getMessage(), e);
+        }
+    }
+}

+ 8 - 0
cfc-backend/src/main/resources/application.yml

@@ -85,6 +85,14 @@ zkxz:
   zx-pass: ""
 
 wechat:
+  # 多小程序路由标识(稳定短码),未显式指定 X-MP-Code 的请求一律回落 default(保持既有行为)
+  default-code: default
+  # 静态兜底 app 表(与 miniapp_config DB 行合并:DB 有值以 DB 为准,DB 无值回落 yml;
+  # 敏感项如 secret/mch-key/api-v3-key 不在此处配置明文,走下面对应 env 引用)
+  apps:
+    default:
+      app-id: wx5ba8038ef16fb245
+      display-name: CFC主小程序
   appid: wx5ba8038ef16fb245
   secret: ${WECHAT_SECRET:3dae525c6f5e010cc034f59abbd1b1cc}
   mch-id: 1703255039

+ 36 - 0
cfc-backend/src/main/resources/schema.sql

@@ -7212,3 +7212,39 @@ CREATE TABLE IF NOT EXISTS service_cost_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_service_cost (service_type, product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务成本价配置';
+
+-- ============================================================
+-- 多小程序配置表(多小程序对接架构方案 §2.1,DatabaseInitializer 迁移465 同步)
+-- 安全约定:密钥/商户密钥只存 env 引用名,明文一律不落库
+-- ============================================================
+CREATE TABLE IF NOT EXISTS miniapp_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    mp_code VARCHAR(32) NOT NULL UNIQUE COMMENT '小程序路由标识(稳定短码,路由/回调/日志用),如 default/xiaoyi/zhishao',
+    display_name VARCHAR(64) COMMENT '展示名,如 CFC主小程序',
+    app_id VARCHAR(32) NOT NULL UNIQUE COMMENT '微信 appid(区分配置的自然键)',
+    secret_ref VARCHAR(128) COMMENT 'secret 的 env 引用名(如 WECHAT_SECRET_XIAOYI);NULL=回落 wechat.secret',
+    mch_id VARCHAR(32) COMMENT '微信支付商户号;NULL=不启用支付',
+    mch_key_ref VARCHAR(128) COMMENT 'mch-key 的 env 引用名',
+    api_v3_key_ref VARCHAR(128) COMMENT 'api-v3-key 的 env 引用名',
+    mch_serial_no VARCHAR(64) COMMENT '商户 API 证书序列号',
+    private_key_path VARCHAR(256) COMMENT 'apiclient_key.pem classpath 路径(每 appid 一份)',
+    notify_url VARCHAR(256) COMMENT '支付回调 URL(可带 /{mpCode} 路径)',
+    login_url VARCHAR(256) DEFAULT 'https://api.weixin.qq.com/sns/jscode2session',
+    phone_url VARCHAR(256) DEFAULT 'https://api.weixin.qq.com/wxa/business/getuserphonenumber',
+    test_mode TINYINT(1) DEFAULT 0 COMMENT '测试模式:1=跳过微信 API',
+    fulfillment_enabled TINYINT(1) DEFAULT 1,
+    virtual_pay_enabled TINYINT(1) DEFAULT 0,
+    appkey_ref VARCHAR(128) COMMENT '虚拟支付现网 appkey 的 env 引用名',
+    sandbox_appkey_ref VARCHAR(128) COMMENT '虚拟支付沙箱 appkey 的 env 引用名',
+    virtual_pay_env TINYINT(1) DEFAULT 0 COMMENT '0=现网 1=沙箱',
+    virtual_pay_token_ref VARCHAR(128) COMMENT '虚拟支付消息推送 Token 的 env 引用名',
+    offer_id VARCHAR(32) COMMENT '虚拟支付应用 ID(每个小程序专属)',
+    alert_template_id VARCHAR(64) COMMENT '订阅消息模板 ID',
+    promotion_tier_template_id VARCHAR(64) COMMENT '促销档位模板 ID',
+    env_version VARCHAR(16) DEFAULT 'release' COMMENT '小程序码环境:dev/test/release',
+    wxacode_login_page VARCHAR(128) DEFAULT 'pages/login/scan-login',
+    enabled TINYINT(1) DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_app_id (app_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多小程序配置表(密钥只存 env 引用名,不存明文)';

+ 145 - 0
cfc-backend/src/test/java/com/etotem/cfc/context/MiniAppContextResolverTest.java

@@ -0,0 +1,145 @@
+package com.etotem.cfc.context;
+
+import com.etotem.cfc.entity.MiniAppConfig;
+import com.etotem.cfc.service.MiniAppConfigService;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.impl.DefaultClaims;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * T02 三路解析验收:Header X-MP-Code / JWT mpCode claim / 回调 URL 路径变量 → default 兜底。
+ *
+ * <p>能力键:wechat.multiapp.context.resolve / wechat.multiapp.context.fallback-default。</p>
+ */
+public class MiniAppContextResolverTest {
+
+    @Mock
+    private MiniAppConfigService configService;
+
+    @InjectMocks
+    private MiniAppContextResolver resolver;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private MiniAppConfig cfg(String code, String appId) {
+        MiniAppConfig c = new MiniAppConfig();
+        c.setMpCode(code);
+        c.setAppId(appId);
+        return c;
+    }
+
+    private DefaultClaims claimsWith(String mpCode) {
+        Map<String, Object> m = new HashMap<>();
+        if (mpCode != null) {
+            m.put("mpCode", mpCode);
+        }
+        return new DefaultClaims(m);
+    }
+
+    @Test
+    public void testResolveFromHeader() {
+        when(configService.getByCode("xiaoyi")).thenReturn(cfg("xiaoyi", "wx_xiaoyi"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.addHeader("X-MP-Code", "xiaoyi");
+
+        MiniAppContext ctx = resolver.resolve(req, null);
+        assertEquals("xiaoyi", ctx.getMpCode());
+        assertEquals("wx_xiaoyi", ctx.getAppId());
+        assertTrue(ctx.isExplicit());
+    }
+
+    @Test
+    public void testResolveFromJwtClaim() {
+        when(configService.getByCode("zhishao")).thenReturn(cfg("zhishao", "wx_zhishao"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+
+        MiniAppContext ctx = resolver.resolve(req, claimsWith("zhishao"));
+        assertEquals("zhishao", ctx.getMpCode());
+        assertEquals("wx_zhishao", ctx.getAppId());
+        assertTrue(ctx.isExplicit());
+    }
+
+    @Test
+    public void testResolveFromCallbackPath() {
+        when(configService.getByCode("xiaoyi")).thenReturn(cfg("xiaoyi", "wx_xiaoyi"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setRequestURI("/api/payment/wechat/v3-notify/xiaoyi");
+
+        MiniAppContext ctx = resolver.resolve(req, null);
+        assertEquals("xiaoyi", ctx.getMpCode());
+    }
+
+    @Test
+    public void testResolveFromVirtualPayPath() {
+        when(configService.getByCode("zhishao")).thenReturn(cfg("zhishao", "wx_zhishao"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setRequestURI("/api/internal/virtual-pay/notify/zhishao");
+
+        MiniAppContext ctx = resolver.resolve(req, null);
+        assertEquals("zhishao", ctx.getMpCode());
+    }
+
+    @Test
+    public void testResolveFromParam() {
+        when(configService.getByCode("xiaoyi")).thenReturn(cfg("xiaoyi", "wx_xiaoyi"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setParameter("mpCode", "xiaoyi");
+
+        MiniAppContext ctx = resolver.resolve(req, null);
+        assertEquals("xiaoyi", ctx.getMpCode());
+    }
+
+    @Test
+    public void testHeaderPriorityOverJwtAndParam() {
+        when(configService.getByCode("xiaoyi")).thenReturn(cfg("xiaoyi", "wx_xiaoyi"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.addHeader("X-MP-Code", "xiaoyi");
+        req.setParameter("mpCode", "zhishao");
+
+        MiniAppContext ctx = resolver.resolve(req, claimsWith("zhishao"));
+        assertEquals("xiaoyi", ctx.getMpCode());
+    }
+
+    @Test
+    public void testFallbackToDefaultWhenNoIdentifiers() {
+        when(configService.getDefaultCode()).thenReturn("default");
+        when(configService.getByCode("default")).thenReturn(cfg("default", "wx_default"));
+        MockHttpServletRequest req = new MockHttpServletRequest();
+
+        MiniAppContext ctx = resolver.resolve(req, null);
+        assertEquals("default", ctx.getMpCode());
+        assertFalse(ctx.isExplicit());
+    }
+
+    @Test
+    public void testUnknownExplicitCodeThrows() {
+        when(configService.getByCode(anyString())).thenReturn(null);
+        when(configService.getDefaultCode()).thenReturn("default");
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.addHeader("X-MP-Code", "ghost");
+
+        assertThrows(IllegalArgumentException.class, () -> resolver.resolve(req, null));
+    }
+
+    @Test
+    public void testExtractPathMpCodeHelper() {
+        assertEquals("xiaoyi", MiniAppContextResolver.extractPathMpCode("/api/payment/wechat/v3-notify/xiaoyi"));
+        assertEquals("zhishao", MiniAppContextResolver.extractPathMpCode("/api/internal/virtual-pay/notify/zhishao"));
+        assertNull(MiniAppContextResolver.extractPathMpCode("/api/user/info"));
+    }
+}

+ 169 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceOpenidIsolationTest.java

@@ -0,0 +1,169 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.context.MiniAppContext;
+import com.etotem.cfc.context.MiniAppContextHolder;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.WechatLoginDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * T03 数据隔离单测:同 openid 在不同小程序下应各自成户,互不覆盖 session_key。
+ *
+ * <p>覆盖能力键 {@code wechat.multiapp.user.openid-isolate}:
+ * 登录按 (openid, appid) 查询,未命中同 appid 用户时按「新建」而非「沿用其他 app 用户」。</p>
+ */
+@ExtendWith(MockitoExtension.class)
+class UserServiceOpenidIsolationTest {
+
+    private static final String OPENID = "oX-test-openid-001";
+    private static final String SK_APP_A = "sk-app-A-abcdef";
+    private static final String SK_APP_B = "sk-app-B-123456";
+
+    @Mock private UserMapper userMapper;
+    @Mock private WechatService wechatService;
+    @Mock private JwtConfig jwtConfig;
+    @Mock private MiniAppConfigService miniAppConfigService;
+    @Mock private OnboardingService onboardingService;
+    @Mock private com.etotem.cfc.mapper.FamilyMapper familyMapper;
+    @Mock private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+    @Mock private CouponService couponService;
+
+    private UserService userService;
+
+    @BeforeEach
+    void setUp() {
+        userService = new UserService();
+        ReflectionTestUtils.setField(userService, "userMapper", userMapper);
+        ReflectionTestUtils.setField(userService, "wechatService", wechatService);
+        ReflectionTestUtils.setField(userService, "jwtConfig", jwtConfig);
+        ReflectionTestUtils.setField(userService, "miniAppConfigService", miniAppConfigService);
+        ReflectionTestUtils.setField(userService, "onboardingService", onboardingService);
+        ReflectionTestUtils.setField(userService, "familyMapper", familyMapper);
+        ReflectionTestUtils.setField(userService, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(userService, "couponService", couponService);
+    }
+
+    @AfterEach
+    void tearDown() {
+        MiniAppContextHolder.clear();
+    }
+
+    private void mockContext(String mpCode, String appId) {
+        MiniAppContextHolder.set(new MiniAppContext(mpCode, appId, null, true));
+    }
+
+    @Test
+    @DisplayName("同 openid 不同 appid 互不可见:silentLogin 跨 app 应报「用户不存在」,且不覆盖 session_key")
+    void testSameOpenidDifferentAppIsolatedInSilentLogin() {
+        Map<String, String> session = new HashMap<>();
+        session.put("openid", OPENID);
+        when(wechatService.code2Session(any(String.class))).thenReturn(session);
+        when(miniAppConfigService.getDefaultAppId()).thenReturn("wx-app-A");
+        mockContext("appB", "wx-app-B");
+        // app B 下查不到该 openid
+        when(userMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> userService.silentLogin("code-appB"));
+        assertTrue(ex.getMessage().contains("用户不存在"));
+        verify(userMapper, never()).updateById(any(User.class));
+    }
+
+    @Test
+    @DisplayName("同 openid 同 appid:silentLogin 命中并保留该 app 的 session_key,token 携带 mpCode claim")
+    void testSameOpenidSameAppResolvesToSameUser() {
+        User existing = new User();
+        existing.setId(101L);
+        existing.setOpenid(OPENID);
+        existing.setAppid("wx-app-A");
+        existing.setSessionKey(SK_APP_A);
+        existing.setRole("parent");
+        existing.setFamilyId(5L); // 已有家庭 → ensureUserHasFamily 提前返回
+
+        Map<String, String> session = new HashMap<>();
+        session.put("openid", OPENID);
+        session.put("session_key", SK_APP_A);
+        when(wechatService.code2Session(any(String.class))).thenReturn(session);
+        when(miniAppConfigService.getDefaultAppId()).thenReturn("wx-app-A");
+        mockContext("appA", "wx-app-A");
+        when(userMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
+        when(jwtConfig.generateToken(eq(101L), any(List.class), eq("appA"))).thenReturn("jwt-appA");
+
+        LoginResultDTO result = userService.silentLogin("code-appA");
+
+        assertNotNull(result);
+        assertEquals(101L, result.getUserId());
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        assertEquals(SK_APP_A, captor.getValue().getSessionKey());
+        assertEquals("wx-app-A", captor.getValue().getAppid());
+        // 三参 generateToken 被调用且 mpCode=appA;两参旧签名不再被调
+        verify(jwtConfig, times(1)).generateToken(eq(101L), any(List.class), eq("appA"));
+        verify(jwtConfig, never()).generateToken(any(Long.class), any(List.class));
+    }
+
+    @Test
+    @DisplayName("wechatLogin 在 app B 下同 openid 未命中时按新建处理,不复用 app A 用户,且写入 app B appid")
+    void testWechatLoginCrossAppCreatesNewUserRecord() {
+        Map<String, String> session = new HashMap<>();
+        session.put("openid", OPENID);
+        session.put("unionid", "unionid-1");
+        session.put("session_key", SK_APP_B);
+        when(wechatService.code2Session(any(String.class))).thenReturn(session);
+        when(miniAppConfigService.getDefaultAppId()).thenReturn("wx-app-A");
+        mockContext("appB", "wx-app-B");
+        // app B 下 openid 查询未命中 → 进入新建分支
+        when(userMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+        when(userMapper.insert(any(User.class))).thenAnswer(inv -> {
+            User u = inv.getArgument(0);
+            u.setId(999L);
+            return 1;
+        });
+        when(userMapper.updateById(any(User.class))).thenReturn(1);
+        when(familyMapper.insert(any(com.etotem.cfc.entity.Family.class))).thenAnswer(inv -> {
+            com.etotem.cfc.entity.Family f = inv.getArgument(0);
+            f.setId(88L);
+            return 1;
+        });
+        when(familyMemberMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
+        when(jwtConfig.generateToken(any(Long.class), any(List.class), any(String.class))).thenReturn("jwt-appB");
+
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("code-appB");
+        LoginResultDTO result = userService.wechatLogin(dto);
+
+        assertNotNull(result);
+        assertTrue(result.getIsNewUser(), "跨 app 同 openid 应视为新用户");
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).insert(captor.capture());
+        assertEquals("wx-app-B", captor.getValue().getAppid());
+        assertEquals(999L, captor.getValue().getId());
+    }
+}