浏览代码

fix(openspec): 规整 add-mjava-pro / add-mjava-com spec 结构

之前 spec.md 放在 change 根目录 + 用 REQ-XXX-NNN 风格,OpenSpec 无法识别为 delta。

本次修复:
- spec 拆到 specs/{capability}/spec.md 子目录
  - add-mjava-com: baas-gateway/ + caller-registry/
  - add-mjava-pro: multi-tenant-runtime/ + tenant-registry/
- 格式改为 ## ADDED Requirements + ### Requirement: X (SHALL/MUST) + #### Scenario: Y
- 删除旧 spec.md
- openspec validate --strict 两个 change 均通过

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 2 周之前
父节点
当前提交
6301776d75

+ 0 - 67
openspec/changes/add-mjava-com/spec.md

@@ -1,67 +0,0 @@
-## baas-gateway
-
-### REQ-GW-001 统一入口
-
-- 路由规则:`POST /api/com/{vendor}/{action}`
-- `vendor` ∈ {`dingtalk`, `aliwork`, `beisen`, `teambition`, `feishu`, `fxiaoke`, `h3yun`, ...}(以 `mjava/service/` 下 vendor 包实际存在为准)
-- `action` 为显式注册的 key,形如 `user.get` / `form.save` / `attendance.listRecord`(命名对应官方 API 文档语义)
-- 请求体 JSON;字段名**必须**与基座 Client 对应方法参数一致
-
-### REQ-GW-002 签名校验
-
-- Header 必含 `X-Caller-Id` `X-Signature` `X-Timestamp`
-- 校验顺序:timestamp 窗口 5 分钟 → callerId 查注册表 → HMAC-SHA256 比对 → 调用方启用状态
-- 任一失败:`401 { code: "AUTH_FAILED", reason: "..." }`(reason 不暴露具体环节,避免用户猜测)
-
-### REQ-GW-003 动作白名单
-
-- action 必须先在 `ActionRegistry` 代码显式注册(反射调基座 Client 方法)
-- 同时必须在 `application.yml` 的 `com.actions.enabled` 列表启用
-- 调用方在宜搭权限表单的 `allowedActions` 包含该 action 才允许访问
-- 三层校验任一不过:`403 { code: "ACTION_FORBIDDEN" }`
-
-### REQ-GW-004 响应格式
-
-- 返回 `McR<T>`(基座已有):`{ success, code, message, data }`
-- 第三方原始响应放在 `data` 字段,保留全部原始结构(不做字段转义)
-- 第三方错误统一映射为 `McR.fail()`,`data` 含 vendor 原始错误码
-
-### REQ-GW-005 限流
-
-- 按 callerId 本地 `RateLimiter`,阈值取宜搭权限表单 `rateLimit` 字段
-- 超限返回 `429 { code: "RATE_LIMITED", retryAfter: 1 }`
-
-### REQ-GW-006 审计扩展
-
-- 日志字段在 `mjava-baseline §3.5` 基础上追加 `callerId` / `vendorAction` / `signatureValid`
-- 日志文件按 callerId 分片
-
-## caller-registry
-
-### REQ-CR-001 注册源
-
-- 调用方白名单维护于宜搭"权限表单"(formUuid 配置在 `com.caller.registry.formUuid`)
-- 字段约定见 `design.md`
-
-### REQ-CR-002 加载与刷新
-
-- 启动时全量拉取 `enabled=on` 的调用方到内存 `Map<callerId, CallerProfile>`
-- 默认 TTL 300 秒,可配 `com.caller.registry.ttlSeconds`
-- 失效后 stale-while-revalidate
-
-### REQ-CR-003 密钥轮换
-
-- 调用方申请新密钥 → 运营在宜搭表单更新 `callerSecret` → 下次拉取后生效
-- 宜搭表单不保留历史版本(如需双密钥轮换,未来提案支持)
-
-### REQ-CR-004 调用方停用
-
-- `enabled=off` 后,注册表下次刷新移除
-- 停用期间返回 `401 CALLER_DISABLED`
-
-## 非目标明示
-
-- 不做签名算法可插拔(固定 HMAC-SHA256;将来如需支持国密再提案)
-- 不做 mTLS / API Gateway 前置
-- 不做请求体加密(HTTPS + HMAC 完整性足够)
-- 不做响应缓存(每次都直调基座 Client;幂等缓存由调用方自行做)

+ 75 - 0
openspec/changes/add-mjava-com/specs/baas-gateway/spec.md

@@ -0,0 +1,75 @@
+## ADDED Requirements
+
+### Requirement: 统一入口
+
+mjava-com SHALL 提供单一网关路由 `POST /api/com/{vendor}/{action}`,每个 action MUST 对应基座某个 Client 方法。
+
+#### Scenario: vendor + action 路由
+
+- **WHEN** 收到 `POST /api/com/dingtalk/user.get`
+- **THEN** 根据 `ActionRegistry` 查找 `dingtalk.user.get` 注册的 handler
+- **AND** 调用基座 `DDClient_Contacts.getUser_v2(...)`
+- **AND** 请求体字段名必须与 Client 方法参数一致
+
+### Requirement: 签名校验
+
+网关 SHALL 在 Auth 拦截阶段校验调用方签名。校验逻辑 MUST 复用基座 `add-request-auth-replay-guard` 提供的 `UtilSignature` 与 `NonceCache`,不重新实现 HMAC。
+
+#### Scenario: 四层校验顺序
+
+- **WHEN** 请求到达
+- **THEN** 依次执行:时间戳窗口 → callerId 注册表查 secret → HMAC-SHA256 比对 → 调用方 enabled
+- **AND** 任一失败返回 `401 { code: "AUTH_FAILED" }`
+- **AND** 响应 message 不暴露具体失败环节
+
+### Requirement: 动作白名单
+
+每个开放的 action MUST 通过三层校验:ActionRegistry 代码注册 + yml `com.actions.enabled` 启用 + 调用方 `allowedActions` 包含。
+
+#### Scenario: 代码未注册
+
+- **WHEN** 请求 action 未在 `ActionRegistry` 注册
+- **THEN** 返回 `404 { code: "ACTION_NOT_FOUND" }`
+
+#### Scenario: yml 未启用
+
+- **WHEN** `com.actions.enabled` 不含该 action
+- **THEN** 返回 `403 { code: "ACTION_FORBIDDEN" }`
+
+#### Scenario: 调用方无权限
+
+- **WHEN** callerId 的 `allowedActions` 不含该 action
+- **THEN** 返回 `403 { code: "ACTION_FORBIDDEN" }`
+
+### Requirement: 响应格式
+
+网关 SHALL 统一返回 `McR<T>`。第三方原始响应 MUST 放在 `data` 字段保留原结构。
+
+#### Scenario: 成功透传
+
+- **WHEN** 基座 Client 返回成功
+- **THEN** `McR.success(result)`,`data = 原始 Map 或 DTO`
+
+#### Scenario: 第三方失败映射
+
+- **WHEN** 基座 Client 抛 McException(vendor 返回错误)
+- **THEN** `McR.fail(code, message)`,`data` 含 vendor 原始错误信息
+
+### Requirement: 限流
+
+网关 SHALL 按 callerId 做本地限流,阈值取自宜搭权限表单 `rateLimit` 字段。
+
+#### Scenario: 超限拒绝
+
+- **WHEN** 同一 callerId 请求速率超过配置 QPS
+- **THEN** 返回 `429 { code: "RATE_LIMITED", retryAfter: 1 }`
+
+### Requirement: 审计扩展
+
+网关审计日志 MUST 在 `mjava-baseline §3.5` 字段基础上追加 callerId / vendorAction / signatureValid。
+
+#### Scenario: 每请求按 caller 分片
+
+- **WHEN** 请求进入
+- **THEN** 日志落入 `./log/{日期}/com-{callerId}.log`
+- **AND** MDC 含 `callerId` 与 `vendorAction`

+ 48 - 0
openspec/changes/add-mjava-com/specs/caller-registry/spec.md

@@ -0,0 +1,48 @@
+## ADDED Requirements
+
+### Requirement: 注册源
+
+调用方白名单 SHALL 维护于宜搭"权限表单"。mjava-com MUST 通过 `com.caller.registry.formUuid` 配置项动态加载调用方清单。
+
+#### Scenario: 启动时全量加载
+
+- **WHEN** 应用启动 `@PostConstruct`
+- **THEN** 按 `com.caller.registry.formUuid` 分页查询宜搭表单
+- **AND** 过滤 `enabled=on` 的记录转为 `CallerProfile` 写入内存 `Map`
+
+### Requirement: 加载与刷新
+
+注册表 SHALL 支持 TTL 自动刷新与 stale-while-revalidate。
+
+#### Scenario: TTL 到期触发刷新
+
+- **WHEN** 距上次加载超过 `com.caller.registry.ttlSeconds`(默认 300 秒)
+- **THEN** 异步重新拉取宜搭表单
+- **AND** 期间旧数据继续可用,直到新数据就绪替换
+
+#### Scenario: 手动刷新入口
+
+- **WHEN** 调用 `POST /api/com/_admin/reloadCallers`
+- **AND** 当前 profile 为 dev
+- **THEN** 立即同步重新加载
+
+### Requirement: 密钥轮换
+
+调用方密钥更新 SHALL 由运营在宜搭表单手动完成。本 change 不支持双密钥并存。
+
+#### Scenario: 单密钥轮换
+
+- **WHEN** 运营更新宜搭表单 `callerSecret`
+- **THEN** 下次 TTL 刷新后新密钥生效
+- **AND** 轮换期间客户端必须同步切换(可接受短暂 401)
+
+### Requirement: 调用方停用
+
+调用方停用 SHALL 在下次注册表刷新后立即生效。停用期间请求 MUST 返回 401。
+
+#### Scenario: enabled=off
+
+- **WHEN** 宜搭表单 `radioField_enabled=off`
+- **AND** 注册表刷新完成
+- **THEN** 该 callerId 从内存 Map 移除
+- **AND** 后续请求返回 `401 { code: "CALLER_DISABLED" }`

+ 0 - 55
openspec/changes/add-mjava-pro/spec.md

@@ -1,55 +0,0 @@
-## multi-tenant-runtime
-
-### REQ-MT-001 租户识别
-
-- Controller 入口通过 `TenantInterceptor` 读 HTTP Header `X-Tenant-Id`
-- Header 缺失:返回 `401 { code: "TENANT_REQUIRED" }`
-- Header 存在但租户不在注册表:返回 `403 { code: "TENANT_NOT_FOUND" }`
-- 识别成功:写入 `TenantContext.set(profile)`;请求结束时 `TenantContext.clear()`(务必在 finally)
-
-### REQ-MT-002 异步任务传递
-
-- `@Async` 线程池必须通过 `TaskDecorator` 复制 TenantContext 到子线程(参考现有 `MdcTaskDecorator`)
-- `CompletableFuture` 手动切换线程时需显式 `TenantContext.propagate(ctx, () -> ...)`
-
-### REQ-MT-003 Token 隔离
-
-- `UtilToken` key 格式统一为 `{tenantId}:{vendor}:{appKey}`
-- `mjava-pro` 不得使用无 tenant 前缀的 UtilToken 接口
-- 无租户上下文(如定时任务)显式传 tenantId,或声明为 `SYSTEM` 伪租户
-
-### REQ-MT-004 审计日志扩展
-
-- `UtilHttp` 审计日志在 `mjava-baseline §3.5` 字段基础上追加 `tenantId`
-- 日志输出目标 `./log/{日期}/pro-{tenantId}.log`(按租户分片,方便定责)
-
-## tenant-registry
-
-### REQ-TR-001 注册源
-
-- 租户配置存放于宜搭"应用表"(formUuid 配置在 `tenant.registry.formUuid`)
-- 字段约定见 `design.md` 表格
-- 访问宜搭本身使用 `application-{profile}.yml` 配置的 `aliwork.appType` / `aliwork.systemToken`(这是 mjava-pro 自身的入口凭据,非租户凭据)
-
-### REQ-TR-002 加载与缓存
-
-- 启动时全量拉取一次,写入 `Map<String, TenantProfile>`
-- TTL:默认 600 秒(`tenant.registry.ttlSeconds` 可覆盖)
-- 过期后首次访问触发异步刷新,旧值继续可用直到新值就绪(stale-while-revalidate)
-- 热刷入口:`POST /api/pro/_admin/reloadTenant`(仅 dev profile)
-
-### REQ-TR-003 敏感字段处理
-
-- appSecret / systemToken 等字段在日志输出时必须脱敏(`***`)
-- 内存中允许保留明文(TenantProfile 字段直接 String,不引入加密存储以保持简单)
-
-### REQ-TR-004 失效与禁用
-
-- 宜搭应用表 `radioField_enabled=off` → 注册表移除该 tenantId
-- 请求携带已禁用 tenantId → 返回 `403 TENANT_DISABLED`
-
-## 非目标明示
-
-- 不定义 tenantId 命名规范(客户代号 / UUID / 数字 ID 均可,由运营决定)
-- 不定义 tenant-level feature flag(有需要另外提案)
-- 不定义多 tenant 同库的 row-level security(共享库方案下由业务 service 自行按 tenantId 过滤)

+ 61 - 0
openspec/changes/add-mjava-pro/specs/multi-tenant-runtime/spec.md

@@ -0,0 +1,61 @@
+## ADDED Requirements
+
+### Requirement: 租户识别
+
+mjava-pro SHALL 在请求入口识别租户上下文。Controller 入口 MUST 通过 `TenantInterceptor` 读 HTTP Header `X-Tenant-Id`。
+
+#### Scenario: Header 缺失
+
+- **WHEN** 请求缺少 `X-Tenant-Id`
+- **THEN** 返回 `401 { code: "TENANT_REQUIRED" }`
+
+#### Scenario: 租户不存在
+
+- **WHEN** Header 存在但租户不在注册表
+- **THEN** 返回 `403 { code: "TENANT_NOT_FOUND" }`
+
+#### Scenario: 识别成功
+
+- **WHEN** tenantId 匹配注册表记录且启用
+- **THEN** `TenantContext.set(profile)` 写入 ThreadLocal
+- **AND** 请求结束时在 finally 块 `TenantContext.clear()`
+
+### Requirement: 异步任务传递
+
+TenantContext MUST 在 @Async 线程池与 CompletableFuture 切换时正确传递,避免子线程拿不到租户凭据。
+
+#### Scenario: @Async 线程池
+
+- **WHEN** 通过 `@Async` 提交任务
+- **THEN** 线程池 TaskDecorator 必须复制 TenantContext 到子线程
+- **AND** 子线程开始执行时 `TenantContext.current()` 返回发起请求的租户
+
+#### Scenario: 手动线程切换
+
+- **WHEN** 代码显式切换线程(如 `CompletableFuture.supplyAsync(executor, ...)`)
+- **THEN** 必须使用 `TenantContext.propagate(ctx, runnable)` 包装
+
+### Requirement: Token 隔离
+
+mjava-pro SHALL 使用带租户前缀的 UtilToken key,避免多租户间 token 互相覆盖。
+
+#### Scenario: key 格式
+
+- **WHEN** 调用 UtilToken 存取
+- **THEN** key 格式统一为 `{tenantId}:{vendor}:{appKey}`
+- **AND** mjava-pro 代码 MUST 不使用无 tenant 前缀的 UtilToken 接口
+
+#### Scenario: 无租户上下文场景
+
+- **WHEN** 定时任务或启动时发起调用
+- **THEN** 必须显式传 tenantId,或声明为 `SYSTEM` 伪租户
+
+### Requirement: 审计日志扩展
+
+mjava-pro SHALL 在 `mjava-baseline §3.5` 字段基础上追加 tenantId,日志按租户分片。
+
+#### Scenario: 按租户分片输出
+
+- **WHEN** 请求进入
+- **THEN** 日志输出到 `./log/{日期}/pro-{tenantId}.log`
+- **AND** MDC 含 `tenantId` 字段

+ 54 - 0
openspec/changes/add-mjava-pro/specs/tenant-registry/spec.md

@@ -0,0 +1,54 @@
+## ADDED Requirements
+
+### Requirement: 注册源
+
+租户配置 SHALL 存放于宜搭"应用表"。mjava-pro MUST 通过 `tenant.registry.formUuid` 配置项加载租户列表。
+
+#### Scenario: 访问宜搭自身凭据
+
+- **WHEN** mjava-pro 启动加载租户表
+- **THEN** 使用 `application-{profile}.yml` 配置的 `aliwork.appType` / `aliwork.systemToken`(mjava-pro 自身入口凭据,不是租户凭据)
+- **AND** 租户凭据从宜搭表记录动态加载
+
+### Requirement: 加载与缓存
+
+租户注册表 SHALL 支持启动全量加载 + TTL 刷新 + stale-while-revalidate。
+
+#### Scenario: 启动全量加载
+
+- **WHEN** 应用启动 `@PostConstruct`
+- **THEN** 分页拉取全部 `enabled=on` 租户记录
+- **AND** 写入内存 `Map<String, TenantProfile>`
+
+#### Scenario: TTL 过期刷新
+
+- **WHEN** 距上次加载超过 `tenant.registry.ttlSeconds`(默认 600 秒)
+- **THEN** 异步重新拉取
+- **AND** 旧值继续可用直到新值就绪
+
+#### Scenario: 热刷入口
+
+- **WHEN** 调用 `POST /api/pro/_admin/reloadTenant?tenantId=xxx`
+- **AND** 当前 profile 为 dev
+- **THEN** 立即刷新单条记录
+
+### Requirement: 敏感字段处理
+
+租户凭据(appSecret / systemToken)MUST 在日志输出时脱敏。内存中允许保留明文。
+
+#### Scenario: 日志脱敏
+
+- **WHEN** 审计日志打印 TenantProfile
+- **THEN** appSecret / systemToken / accessKey 等字段输出为 `***`
+- **AND** tenantId / vendor / corpId 等非敏感字段正常输出
+
+### Requirement: 失效与禁用
+
+宜搭表 `enabled=off` 的租户 MUST 在下次刷新后移除。停用期间请求 MUST 返回 403。
+
+#### Scenario: 禁用生效
+
+- **WHEN** 宜搭表单 `radioField_enabled=off`
+- **AND** 注册表刷新完成
+- **THEN** 该 tenantId 从内存 Map 移除
+- **AND** 携带此 tenantId 的请求返回 `403 { code: "TENANT_DISABLED" }`