## 架构要点 ### 租户识别 ``` Request → TenantInterceptor (读 X-Tenant-Id) → TenantContext.set(tenantId) → Controller → Service → Client ↓ Client 从 TenantContext 拿 appKey/appSecret ``` `TenantContext` 用 `ThreadLocal`;`TenantProfile` 包含: - `tenantId`(宜搭应用表主键) - `vendorCredentials: Map`(钉钉/aliwork 等) - `extraJson`(tenant 级自定义配置) ### 租户配置加载 **数据源**:宜搭"应用表"(formUuid 配置)。字段约定: | 宜搭字段 | 含义 | |---------|------| | `textField_tenantId` | 租户唯一标识(建议用客户代号如 `guangming`) | | `textField_vendor` | 第三方平台 key(`dingtalk` / `aliwork` / ...) | | `textField_appKey` | 该 vendor 的 appKey | | `textField_appSecret` | 该 vendor 的 appSecret(写入时走宜搭加密字段) | | `textField_corpId` | 可选(钉钉需要) | | `textareaField_extraJson` | JSON 字符串,扩展参数 | | `radioField_enabled` | `on` / `off` | **加载策略**: - 启动时全量拉取 → 内存 `Map` - TTL 10 分钟(可配 `tenant.registry.ttlSeconds`)过期后按需刷新 - 手动 `/pro/_admin/reloadTenant?tenantId=xxx`(仅 dev profile 开放)做热刷 ### Client 透传机制 基座 `DDClient` 签名不变(仍接受 appKey/appSecret 参数),`mjava-pro` 提供一层 `DynamicDDService` 组合: ```java // Service 层内部 DDConf conf = TenantContext.current().credential("dingtalk").toDDConf(); ddClient.listDepartmentUserDetail(conf, deptId, cursor, size); ``` **不做** AOP 反射修改签名 — 保持显式、可读、可调试。 ### Token 缓存隔离 `UtilToken` 当前 key 是裸 `{vendor}:{appKey}`。多租户环境下两个客户可能共用同一 appKey(极少,但要防撞)。建议基座支持 namespace: ```java UtilToken.put(tenantId, "dingtalk:" + appKey, token, expireSec); UtilToken.get(tenantId, "dingtalk:" + appKey); ``` 若基座改动太大,**回退方案**:`mjava-pro` 约束 key 为 `{tenantId}:{vendor}:{appKey}`,不改基座。 ### 错误处理 - tenantId 缺失 → `401 TENANT_REQUIRED` - tenantId 不存在或 disabled → `403 TENANT_NOT_FOUND` - vendor 配置缺失 → `500 TENANT_VENDOR_MISCONFIGURED` + 审计日志 ## 技术约束 - 沿用 `mjava-baseline.md` 全部规范 - `application-prod.yml` 只配宜搭应用表 formUuid / systemToken(访问宜搭自身的凭据仍是静态),不配任何客户凭据 - 请求监听日志必须含 `tenantId` 字段(在 §3.5 审计字段基础上扩展) ## Non-Goals(设计边界) - 不考虑租户级数据库隔离(所有租户共享同一 MySQL) - 不考虑租户级流量限额(QPS 限流等;有需求再单独提案) - 不考虑跨租户工作流(暂时假设每次请求单租户上下文) - 不考虑 Redis 分布式缓存(单实例 TimedCache 足够 Phase 1)