浏览代码

docs(mjava-com): 外部对接指南 + HMAC 签名算法 + Python/Node.js 样例

- 调用协议: 4 个必填 Header (X-Caller-Id / X-MJ-Timestamp / X-MJ-Nonce / X-MJ-Signature) + JSON body
- 签名算法: HMAC-SHA256(secret, ts + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256_hex(body))
- 完整 Python / Node.js 客户端样例代码
- 错误码表: AUTH_* / RATE_LIMITED / ACTION_NOT_FOUND / ACTION_FORBIDDEN / VENDOR_ERROR
- 调用方注册流程 + 暴露白名单双层校验 + 限流策略
- tasks.md 7.3 勾选; 7.4 标 stale; 3.3/3.4 ActionRegistry 首批 action 仍按 proposal 延后等首调用方
malk 1 周之前
父节点
当前提交
7af06b8a49
共有 2 个文件被更改,包括 177 次插入2 次删除
  1. 175 0
      mjava-com/README.md
  2. 2 2
      openspec/changes/add-mjava-com/tasks.md

+ 175 - 0
mjava-com/README.md

@@ -0,0 +1,175 @@
+# mjava-com
+
+通用能力 BaaS 网关。把 mjava 基座的 Client/Service 能力以 REST 暴露给外部系统(Node.js / Python / 宜搭连接器 / 低代码平台)。
+
+> 客户分档:`mjava-com` 本身不归属任何客户档,是面向外部调用方的通用入口。暴露白名单见 `openspec/changes/define-customer-tiering/specs/customer-tiering/spec.md` R5。
+
+## 端口与上下文
+
+- 端口:`9020`
+- context-path:`/api/com`
+- 入口:`POST /api/com/{vendor}/{action}`,如 `POST /api/com/dingtalk/user.get`
+
+## 调用协议
+
+### Headers(必填)
+
+| Header | 说明 |
+|---|---|
+| `X-Caller-Id` | 调用方 ID(宜搭权限表注册) |
+| `X-MJ-Timestamp` | 当前 Unix 毫秒时间戳;与服务端时间窗 5 分钟外拒绝 |
+| `X-MJ-Nonce` | 单次随机串(16~64 字符);服务端去重(NonceCache 防重放) |
+| `X-MJ-Signature` | 见下方签名算法 |
+| `Content-Type` | `application/json` |
+
+### Body
+
+JSON 对象,作为 action 的入参;无入参传 `{}`。
+
+### 签名算法
+
+```
+content   = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256_hex(body)
+signature = hex(HMAC_SHA256(secret, content))
+```
+
+- `METHOD` 大写(`POST`)
+- `path` 是 servlet path(**含** context-path,如 `/api/com/dingtalk/user.get`)
+- `body` 是原始请求体字节(与发出的字节一致;JSON 序列化后的字符串)
+- 空 body 时 `sha256_hex("")`
+
+## 调用方样例
+
+### Python
+
+```python
+import hashlib
+import hmac
+import json
+import time
+import uuid
+import requests
+
+CALLER_ID = "your-caller-id"
+SECRET    = "your-caller-secret"   # 由 mjava-com 运维分配
+BASE_URL  = "https://your-host/api/com"
+
+def call(vendor: str, action: str, body: dict) -> dict:
+    path = f"/api/com/{vendor}/{action}"
+    body_str = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
+    ts = str(int(time.time() * 1000))
+    nonce = uuid.uuid4().hex
+    body_hash = hashlib.sha256(body_str.encode("utf-8")).hexdigest()
+    content = "\n".join([ts, nonce, "POST", path, body_hash])
+    signature = hmac.new(SECRET.encode("utf-8"), content.encode("utf-8"), hashlib.sha256).hexdigest()
+
+    headers = {
+        "Content-Type": "application/json",
+        "X-Caller-Id": CALLER_ID,
+        "X-MJ-Timestamp": ts,
+        "X-MJ-Nonce": nonce,
+        "X-MJ-Signature": signature,
+    }
+    r = requests.post(BASE_URL + path[len("/api/com"):], data=body_str.encode("utf-8"), headers=headers, timeout=10)
+    r.raise_for_status()
+    return r.json()
+
+# 示例
+print(call("dingtalk", "user.get", {"userid": "U123"}))
+```
+
+### Node.js
+
+```javascript
+const crypto = require("crypto");
+const axios = require("axios");
+
+const CALLER_ID = "your-caller-id";
+const SECRET    = "your-caller-secret";
+const BASE_URL  = "https://your-host/api/com";
+
+async function call(vendor, action, body) {
+  const path     = `/api/com/${vendor}/${action}`;
+  const bodyStr  = JSON.stringify(body);
+  const ts       = String(Date.now());
+  const nonce    = crypto.randomBytes(16).toString("hex");
+  const bodyHash = crypto.createHash("sha256").update(bodyStr).digest("hex");
+  const content  = [ts, nonce, "POST", path, bodyHash].join("\n");
+  const signature = crypto.createHmac("sha256", SECRET).update(content).digest("hex");
+
+  const { data } = await axios.post(`${BASE_URL}/${vendor}/${action}`, bodyStr, {
+    headers: {
+      "Content-Type":    "application/json",
+      "X-Caller-Id":     CALLER_ID,
+      "X-MJ-Timestamp":  ts,
+      "X-MJ-Nonce":      nonce,
+      "X-MJ-Signature":  signature,
+    },
+  });
+  return data;
+}
+
+call("dingtalk", "user.get", { userid: "U123" }).then(console.log);
+```
+
+## 错误码
+
+| code | HTTP | 含义 |
+|---|---|---|
+| `AUTH_HEADER_MISSING` | 401 | 4 个必填 Header 任一缺失 |
+| `AUTH_TIMESTAMP_EXPIRED` | 401 | timestamp 超出 5 分钟窗口 |
+| `AUTH_CALLER_NOT_FOUND` | 401 | callerId 未注册或已停用 |
+| `AUTH_NONCE_REPLAYED` | 401 | nonce 重复(防重放命中) |
+| `AUTH_SIGNATURE_INVALID` | 403 | 签名校验失败 |
+| `RATE_LIMITED` | 429 | 超出 caller 限流(默认 60 QPS) |
+| `ACTION_NOT_FOUND` | 404 | `{vendor}.{action}` 未在 `ActionRegistry` 注册 |
+| `ACTION_FORBIDDEN` | 403 | action 未在 yml 全局白名单或 caller 的 allowedActions |
+| `VENDOR_ERROR` | 500 | 第三方调用失败 |
+
+## 调用方注册
+
+> 标准注册流程见 `define-customer-tiering` spec R5。
+
+调用方在宜搭权限表登记(formUuid 配在 `application.yml` `com.caller.registry.formUuid`):
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `callerId` | ✅ | 全局唯一 |
+| `callerSecret` | ✅ | HMAC 密钥(宜搭加密字段) |
+| `allowedActions` | ✅ | 允许调用的 `{vendor}.{action}` 数组(`*` 表全开放,慎用) |
+| `rateLimit` | 可选 | QPS(默认 60) |
+| `expireAt` | 可选 | 失效时间戳;过期自动拒绝 |
+| `enabled` | ✅ | false 拒绝该 caller |
+
+热加载:`POST /_admin/reloadCaller`(dev profile)或等下次 `@Scheduled` 刷新(默认 TTL 配在 `application.yml`)。
+
+## 暴露白名单
+
+只有同时满足两个白名单的 action 才能被调用:
+
+1. **代码层**:`ActionRegistry` 中通过 `register(key, handler)` 注册的 handler
+2. **配置层**:`application.yml` 的 `com.actions.enabled` 列出的 key
+
+危险动作(含 `delete` / `remove` / `drop` / `truncate` / `batch + 写`)即使两层白名单都通过,仍需主仓维护者在 PR 中显式 ACK 才能开启。
+
+## 限流策略
+
+- 默认每 caller 60 QPS(本地 `Guava RateLimiter`,进程级,**不跨实例**)
+- 在 `application.yml` 按 caller 覆盖:`com.rateLimit.{callerId}: {qps}`
+- 超出返 `429 RATE_LIMITED`
+
+## 配置文件
+
+| 文件 | 说明 |
+|---|---|
+| `application.yml` | 通用配置 |
+| `application-dev.yml.example` | dev profile 占位(含 com.caller.registry.* / com.actions.enabled 示例) |
+| `application-prod.yml.example` | prod profile 占位 |
+| `application-{profile}.yml` | 实际配置(**不入 git**) |
+
+## 关联文档
+
+- 客户分档 spec:`openspec/changes/define-customer-tiering/specs/customer-tiering/spec.md` R5
+- BaaS 网关 capability:`openspec/changes/add-mjava-com/specs/baas-gateway/` + `specs/caller-registry/`
+- 基座 Client/Service 分层:`openspec/specs/client-service-layering/spec.md` R1~R7
+- 后端开发主规范:`/Users/malk/Desktop/Tech/claude/后端/CLAUDE.md`

+ 2 - 2
openspec/changes/add-mjava-com/tasks.md

@@ -42,8 +42,8 @@
 
 - [x] 7.1 `application-dev.yml.example`(com.caller.registry.* + com.actions.enabled 示例)— 2026-04-26
 - [x] 7.2 `application-prod.yml.example`(同上占位)— 2026-04-26 复核已存在
-- [ ] 7.3 `README.md`:外部系统对接步骤、signature 算法示例(Python / Node.js 样例代码
-- [ ] 7.4 更新 `mjava-baseline.md` 表格里 mjava-com 状态为"已实施"
+- [x] 7.3 `mjava-com/README.md`:调用协议(4 Header + 签名算法)+ Python/Node.js 样例 + 错误码 + 注册流程 + 白名单 + 限流(2026-06-10
+- [ ] 7.4 ~~mjava-baseline.md~~ stale 引用(实际权威是 `后端/CLAUDE.md`);状态更新待 add-mjava-com 整体 archive 时再回写
 
 ## 8. 验证