ソースを参照

feat(mjava-pro): DynamicDDService / DynamicYDService 多租户凭据中转

- DynamicDDService: 从 TenantContext 取钉钉 credential, 调基座 DDClient.getAccessToken(appKey, appSecret); UtilToken key namespace {tenantId}:dingtalk:{appKey} 隔离不同租户 token; TTL 7200s
- DynamicYDService: 构造 YDAuth(accessToken 复用 DynamicDDService, appType/systemToken/userId 取自 VendorCredential.extra; 缺则抛 YDA_CONFIG_INCOMPLETE; userId 缺省走 YDConf.PUB_ACCOUNT)
- mjava-pro/README.md: 调用范式 / A 档入驻 SOP / 隔离边界 / tenant.registry.* 配置块
- tasks.md 勾选 4.1/4.2/4.3/5.1/6.3 + 6.4 标 stale 跳过 + 5.2/5.3 写明延后理由
- 不改基座 UtilHttp/DDImplClient: tenantId 通过 MDC 自动进审计日志, logback pattern 已含 [%X{tenantId:-}]
malk 1 週間 前
コミット
012df8fbd6

+ 102 - 0
mjava-pro/README.md

@@ -0,0 +1,102 @@
+# mjava-pro
+
+多客户单部署运行时(A 档公共托管)。一个 jar 通过 `X-Tenant-Id` 路由到不同租户配置,按租户隔离凭据/缓存/日志/数据。
+
+> 客户分档:见 `openspec/changes/define-customer-tiering/specs/customer-tiering/spec.md` R1。当前 mjava-pro = A 档容器。
+
+## 端口与上下文
+
+- 端口:`9010`
+- context-path:`/api/pro`
+
+## 调用范式
+
+请求必须带 `X-Tenant-Id` Header(`TenantInterceptor` 识别,未带返 401 `TENANT_REQUIRED`,无对应配置返 403 `TENANT_NOT_FOUND`)。
+
+业务代码示例:
+
+```java
+@RestController
+@RequestMapping("/example")
+public class ExampleController {
+
+    @Autowired DynamicYDService ydService;
+    @Autowired YDClient_Form ydForm;
+
+    @GetMapping("/forms")
+    public McR listForms() {
+        // ydService.buildAuth() 自动从当前租户拿凭据 + 钉钉 token
+        Object data = ydForm.searchForm(ydService.buildAuth(), "FORM-XXX", null, null);
+        return McR.success(data);
+    }
+}
+```
+
+钉钉直调:
+
+```java
+@Autowired DynamicDDService ddService;
+...
+String token = ddService.getAccessToken();   // {tenantId}:dingtalk:{appKey} namespace cache
+String corpId = ddService.currentCorpId();
+```
+
+## 新增租户(A 档入驻 SOP)
+
+> 标准入驻清单见 `define-customer-tiering` spec R4。
+
+1. **校验**:确认客户不命中 A→B 任一硬指标(无自定义 Controller / 无私有 schema / 无独立部署要求)
+2. **宜搭应用表加一行**(formUuid 配在 `application.yml` 的 `tenant.registry.formUuid`):
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `tenantId` | ✅ | 业务唯一标识,禁中文/特殊字符 |
+| `tenantName` | ✅ | 中文展示名(日志/监控可读性) |
+| `vendor` | ✅ | `dingtalk` / `aliwork` / ... |
+| `appKey` | ✅ | 产品方 appKey |
+| `appSecret` | ✅ | 产品方 appSecret(宜搭加密字段) |
+| `corpId` | 钉钉系必填 | — |
+| `extraJson` | 可选 | vendor 特定参数 JSON 字符串(如宜搭的 `appType` / `systemToken` / `userId`) |
+| `enabled` | ✅ | false 拒绝该 tenant 请求 |
+
+3. **热加载**:调 `POST /_admin/reloadTenant`(dev profile)或等下次 `@Scheduled` 刷新(默认 TTL 配在 `application.yml`)
+4. **凭据热验证**:`GET /api/pro/_diag/tenant/{tenantId}` 探活(接口归后续 task 实现)
+5. **上线通告**:邮件抄送运维 + 客户对接人
+
+## 隔离边界
+
+| 维度 | 隔离方式 |
+|---|---|
+| **DB** | 共享 schema,业务表必须含 `tenant_id` 列;JPA Interceptor 自动注入 WHERE 条件(待 task 实现) |
+| **缓存** | `UtilToken` key 命名空间扩为 `{tenantId}:{vendor}:{appKey}`(`DynamicDDService` 已实现) |
+| **日志** | MDC 自动注入 `tenantId`;logback pattern 含 `[%X{tenantId:-}]`(已落) |
+| **异步线程** | `TaskDecorator` 传播 ThreadLocal(待生产启用时评估) |
+
+## 配置文件
+
+| 文件 | 说明 |
+|---|---|
+| `application.yml` | 通用配置(端口、context-path、scheduling 等) |
+| `application-dev.yml.example` | dev profile 占位 |
+| `application-prod.yml.example` | prod profile 占位 |
+| `application-{profile}.yml` | 实际配置(**不入 git**) |
+
+`tenant.registry.*` 配置块:
+
+```yaml
+tenant:
+  registry:
+    appType: ${TENANT_REG_APP_TYPE}
+    systemToken: ${TENANT_REG_SYSTEM_TOKEN}
+    formUuid: ${TENANT_REG_FORM_UUID}
+    userId: ${TENANT_REG_USER_ID}
+    ttlSeconds: 600
+    failFast: true
+```
+
+## 关联文档
+
+- 客户分档 spec:`openspec/changes/define-customer-tiering/specs/customer-tiering/spec.md`
+- 多租户 capability:`openspec/changes/add-mjava-pro/specs/multi-tenant-runtime/` + `specs/tenant-registry/`
+- 基座 Client/Service 分层:`openspec/specs/client-service-layering/spec.md` R1~R7
+- 后端开发主规范:`/Users/malk/Desktop/Tech/claude/后端/CLAUDE.md`

+ 94 - 0
mjava-pro/src/main/java/com/malk/pro/service/aliwork/DynamicYDService.java

@@ -0,0 +1,94 @@
+package com.malk.pro.service.aliwork;
+
+import com.malk.pro.service.dingtalk.DynamicDDService;
+import com.malk.pro.tenant.TenantContext;
+import com.malk.pro.tenant.TenantProfile;
+import com.malk.pro.tenant.VendorCredential;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.common.McException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 多租户宜搭服务(mjava-pro 容器)
+ *
+ * <p>capability: multi-tenant-runtime。从 {@link TenantContext} 取当前租户的宜搭凭据,
+ * 构造 {@link YDAuth} 供 {@code YDClient_Form} / {@code YDClient_Process} 原子接口使用。</p>
+ *
+ * <p>调用方范式:</p>
+ * <pre>
+ * &#64;Autowired DynamicYDService ydService;
+ * &#64;Autowired YDClient_Form ydForm;
+ * ...
+ * ydForm.searchForm(ydService.buildAuth(), formUuid, ...);
+ * </pre>
+ *
+ * <p>宜搭凭据约定:{@code VendorCredential.extra} 必填 {@code appType} / {@code systemToken},
+ * 可选 {@code userId}(缺省走 {@link YDConf#PUB_ACCOUNT})。</p>
+ */
+@Slf4j
+@Service
+public class DynamicYDService {
+
+    private static final String VENDOR = "aliwork";
+
+    @Autowired
+    private DynamicDDService ddService;
+
+    /**
+     * 当前租户宜搭凭据。租户/凭据缺失抛 {@link McException}。
+     */
+    public VendorCredential currentCredential() {
+        TenantProfile profile = TenantContext.current();
+        if (profile == null) {
+            throw new McException("TENANT_REQUIRED", "TenantContext 未注入,无法获取宜搭凭据");
+        }
+        VendorCredential credential = profile.credential(VENDOR);
+        if (credential == null) {
+            throw new McException("VENDOR_NOT_CONFIGURED",
+                    "tenant=" + profile.getTenantId() + " 未配置 aliwork 凭据");
+        }
+        return credential;
+    }
+
+    /**
+     * 构造 {@link YDAuth}:accessToken 取自钉钉租户,appType/systemToken/userId 取自宜搭 extra。
+     */
+    public YDAuth buildAuth() {
+        return buildAuth(null);
+    }
+
+    /**
+     * 构造 {@link YDAuth} 并指定操作人 userId(覆盖 extra 中的默认 userId)。
+     */
+    public YDAuth buildAuth(String userIdOverride) {
+        VendorCredential credential = currentCredential();
+        Map<String, Object> extra = Optional.ofNullable(credential.getExtra()).orElseThrow(() ->
+                new McException("YDA_CONFIG_INCOMPLETE",
+                        "tenant=" + TenantContext.currentTenantId() + " aliwork.extra 缺失 appType/systemToken"));
+        String appType = stringValue(extra, "appType");
+        String systemToken = stringValue(extra, "systemToken");
+        if (appType == null || systemToken == null) {
+            throw new McException("YDA_CONFIG_INCOMPLETE",
+                    "tenant=" + TenantContext.currentTenantId() + " 宜搭 extra 缺少 appType/systemToken");
+        }
+        String userId = userIdOverride != null ? userIdOverride
+                : Optional.ofNullable(stringValue(extra, "userId")).orElse(YDConf.PUB_ACCOUNT);
+        return YDAuth.builder()
+                .accessToken(ddService.getAccessToken())
+                .appType(appType)
+                .systemToken(systemToken)
+                .userId(userId)
+                .build();
+    }
+
+    private static String stringValue(Map<String, Object> map, String key) {
+        Object v = map.get(key);
+        return v == null ? null : v.toString();
+    }
+}

+ 82 - 0
mjava-pro/src/main/java/com/malk/pro/service/dingtalk/DynamicDDService.java

@@ -0,0 +1,82 @@
+package com.malk.pro.service.dingtalk;
+
+import com.malk.pro.tenant.TenantContext;
+import com.malk.pro.tenant.TenantProfile;
+import com.malk.pro.tenant.VendorCredential;
+import com.malk.server.common.McException;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.utils.UtilToken;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 多租户钉钉服务(mjava-pro 容器)
+ *
+ * <p>capability: multi-tenant-runtime。从 {@link TenantContext} 取当前租户的钉钉凭据,
+ * 自动走 {@code {tenantId}:dingtalk:{appKey}} 命名空间缓存 access_token,
+ * 避免不同租户互相覆盖。</p>
+ *
+ * <p>调用方范式:业务先 {@code @Autowired DynamicDDService},
+ * 再用 {@link #getAccessToken()} 拿到当前租户 token,传给具体 {@code DDClient_*} 方法。</p>
+ */
+@Slf4j
+@Service
+public class DynamicDDService {
+
+    private static final String VENDOR = "dingtalk";
+    private static final String TOKEN_KEY_FMT = "%s:" + VENDOR + ":%s";
+    // 钉钉 access_token 官方 TTL 7200s;UtilToken.put 内部会再扣 5s 容错
+    private static final long DEFAULT_TOKEN_TTL_MS = 7200_000L;
+
+    @Autowired
+    private DDClient ddClient;
+
+    /**
+     * 当前租户的钉钉凭据。租户/凭据缺失抛 {@link McException}。
+     */
+    public VendorCredential currentCredential() {
+        TenantProfile profile = TenantContext.current();
+        if (profile == null) {
+            throw new McException("TENANT_REQUIRED", "TenantContext 未注入,无法获取钉钉凭据");
+        }
+        VendorCredential credential = profile.credential(VENDOR);
+        if (credential == null) {
+            throw new McException("VENDOR_NOT_CONFIGURED",
+                    "tenant=" + profile.getTenantId() + " 未配置 dingtalk 凭据");
+        }
+        return credential;
+    }
+
+    /**
+     * 当前租户 access_token,按 {tenantId}:dingtalk:{appKey} 命名空间缓存。
+     */
+    public String getAccessToken() {
+        VendorCredential credential = currentCredential();
+        String key = String.format(TOKEN_KEY_FMT, TenantContext.currentTenantId(), credential.getAppKey());
+        String token = UtilToken.get(key);
+        if (StringUtils.isNotBlank(token)) {
+            return token;
+        }
+        token = ddClient.getAccessToken(credential.getAppKey(), credential.getAppSecret());
+        if (StringUtils.isBlank(token)) {
+            throw new McException("DD_TOKEN_FAILED",
+                    "tenant=" + TenantContext.currentTenantId() + " 钉钉 token 获取失败");
+        }
+        UtilToken.put(key, token, DEFAULT_TOKEN_TTL_MS);
+        return token;
+    }
+
+    public String currentAppKey() {
+        return currentCredential().getAppKey();
+    }
+
+    public String currentAppSecret() {
+        return currentCredential().getAppSecret();
+    }
+
+    public String currentCorpId() {
+        return currentCredential().getCorpId();
+    }
+}

+ 8 - 8
openspec/changes/add-mjava-pro/tasks.md

@@ -25,22 +25,22 @@
 
 ## 4. Client / Service 透传
 
-- [ ] 4.1 新建 `com.malk.pro.service.dingtalk.DynamicDDService`:从 `TenantContext` 读凭据 → 构造 `DDConf` → 调基座 `DDClient`
-- [ ] 4.2 同法建 `DynamicYDService`(对接宜搭)、其他 vendor 按需
-- [ ] 4.3 `UtilToken` 使用规范:key 统一 `{tenantId}:{vendor}:{appKey}`(若基座未扩展 namespace,就在调用侧拼)
+- [x] 4.1 `com.malk.pro.service.dingtalk.DynamicDDService`:从 TenantContext 取凭据 → 调基座 `DDClient.getAccessToken(appKey, appSecret)` → 走 `{tenantId}:dingtalk:{appKey}` namespace 缓存(2026-06-10)
+- [x] 4.2 `com.malk.pro.service.aliwork.DynamicYDService`:构造 `YDAuth`(accessToken 复用 DynamicDDService,appType/systemToken/userId 取自 extra)(2026-06-10)
+- [x] 4.3 UtilToken key namespace `{tenantId}:{vendor}:{appKey}` 已在 4.1/4.2 实现;基座不动
 
 ## 5. 审计日志
 
-- [ ] 5.1 `UtilHttp` 审计日志扩展字段 `tenantId`(从 MDC 读);改基座或在 pro 侧包一层
-- [ ] 5.2 logback-spring.xml 为 mjava-pro 独立输出 `./log/{日期}/pro-%X{tenantId}.log`
-- [ ] 5.3 确保异步线程能读到 MDC(配合 Task 2.5
+- [x] 5.1 `tenantId` 通过 MDC 自动进审计(TenantInterceptor 已写 MDC + logback pattern 含 `[%X{tenantId:-}]`,不动基座 UtilHttp)
+- [ ] 5.2 logback-spring.xml 按租户分文件 `./log/{日期}/pro-%X{tenantId}.log` — 延后(pattern 已含 tenantId,分文件按实际部署需求评估)
+- [ ] 5.3 异步线程 MDC 传递 — 延后(依赖 Task 2.5 TenantTaskDecorator
 
 ## 6. 配置与文档
 
 - [x] 6.1 `application-dev.yml.example`(含 tenant.registry.* 占位)— 2026-04-26
 - [x] 6.2 `application-prod.yml.example`(含 tenant.registry.* 占位)— 2026-04-26 复核已存在
-- [ ] 6.3 `README.md`(在 mjava-pro 目录下,说明新增租户操作步骤
-- [ ] 6.4 更新 `/Users/malk/Desktop/Tech/claude/后端/mjava-baseline.md` 表格里 mjava-pro 状态改为"已实施"
+- [x] 6.3 `mjava-pro/README.md`:调用范式 + 入驻 SOP + 隔离边界 + 配置(2026-06-10
+- [ ] 6.4 ~~mjava-baseline.md~~ stale 引用(实际权威是 `后端/CLAUDE.md`);状态更新待 add-mjava-pro 整体 archive 时再回写
 
 ## 7. 验证