소스 검색

docs(openspec): 立项 add-integration-user-api(集成平台用户域 API)

新建 vendor service/integration/,本期实现:
- OAuth2 client_credentials 鉴权 + UtilToken 缓存
- 用户 create / update / delete / query 4 个原子接口

文档来源:apifox 公开站;设计决策含字段映射、token TTL、
endpoint 单复数 / HTTP 方法陷阱等 5 项风险点。

不在本期:org / 角色 / 启用禁用 / 改密码 / Webhook /
mjava-pro 多租户 / 高阶 Service。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 1 개월 전
부모
커밋
e2e1ba8e04

+ 1 - 0
openspec/BACKLOG.md

@@ -41,6 +41,7 @@
 | B1.2 | **钉钉通讯录** API 对齐规则与覆盖度矩阵 | `changes/extend-dingtalk-contacts-api/` | ✅ 规则就绪 |
 | B1.3 | 钉钉其余模块(考勤/审批/消息/群聊/...)| 未立项 | ⏸ 暂缓,待用户指令 |
 | B1.4 | 其他 vendor(北森/teambition/fxiaoke/h3yun/vika/xbongbong/feishu/ekuaibao)| 未立项 | ⏸ 低优先级,按需补 |
+| B1.7 | **集成平台**用户域 API(鉴权 + create/update/delete/query) | `changes/add-integration-user-api/` | 🔨 active 0/14 — 2026-04-26 立项,待 apply |
 | B1.5 | **实施**:宜搭原子 Client 落地(`/opsx:apply extend-yida-api-coverage`) | — | ⏳ 待用户触发 |
 | B1.6 | **实施**:钉钉通讯录原子 Client 落地(`/opsx:apply extend-dingtalk-contacts-api`) | — | ⏳ 待用户触发 |
 

+ 86 - 0
openspec/changes/add-integration-user-api/design.md

@@ -0,0 +1,86 @@
+# 集成平台用户接口设计
+
+> 文档来源:apifox 公开站 `https://uj0cyahyvs.apifox.cn/`(共 29 个 API;本期取 5 个)
+> 抓取时间:2026-04-26
+
+## 覆盖度矩阵
+
+| 接口 | apifox ID | Method | Path | 本期 |
+|------|-----------|--------|------|------|
+| 获取 access_token(请求体方式) | 288915790 | POST | `/iam/token` | ✅ |
+| 获取 access_token(Basic 方式) | 288915791 | POST | `/iam/token/basic` | ⏸ 暂不实现,请求体方式更通用 |
+| 创建用户 | 288915800 | POST | `/iam/api/users` | ✅ |
+| 修改用户 | 288915803 | PATCH | `/iam/api/user` | ✅ |
+| 删除用户 | 288915805 | POST | `/iam/api/users/delete` | ✅ |
+| 查询用户 | 288915799 | GET | `/iam/api/users` | ✅(顺手对齐,便于联调验证) |
+| 其它 23 个接口(org / 角色 / Webhook / 连接器 / token basic / 启用禁用 / 改密码 / ...) | 288915790-288915818 | - | - | ⏸ 后续按需另立 change |
+
+## 关键决策
+
+### D1. 鉴权方式选「请求体」而非「Basic」
+
+两种方式服务端等价。选请求体方式(`/iam/token`)原因:
+- `Basic base64(client_id:client_secret)` 需要在客户端做编码 + Header 注入,封装复杂度更高
+- 请求体方式直接 form body 三个字段,与 mjava 现有 vendor token 拉取风格一致
+
+### D2. token 缓存走 `UtilToken`,namespace=`integration`
+
+- key:`integration:accessToken`(基座单租户场景,无需拼 clientId)
+- TTL:服务端返回 `expires_in`,本地缓存设为 `expires_in - 60`(提前 60s 失效,避免边界问题)
+- 缓存 miss 或过期 → `IntpClientImpl.getAccessToken()` 同步拉新
+
+### D3. Client 方法签名遵循 baseline §3.4.2
+
+- 第一个参数始终是 `String accessToken`(按现有 dingtalk/aliwork Client 风格)
+- 必填字段显式入参(`username`、`password`、`usernames`)
+- 可选字段统一打入 `Map<String, Object> body_ext`,javadoc 完整枚举字段名 + 类型 + 含义
+
+具体签名:
+
+```java
+McR getAccessToken();                                                           // 内部用,不进 Client 接口
+McR createUser(String accessToken, String username, String password,
+               Map<String, Object> body_ext);                                   // body_ext: name/email/phone_number/user_job_number/nick_name/picture/org_ids/address/title/hired_date/tag/group_positions
+McR updateUser(String accessToken, String username,
+               Map<String, Object> body_ext);                                   // username 走 Query Param,body 全是可选字段
+McR deleteUsers(String accessToken, List<String> usernames);                    // body: { "usernames": [...] }
+McR queryUsers(String accessToken, Map<String, Object> query);                  // query: q/org_id/page/size/attrs/return_users_in_sub_org
+```
+
+### D4. 响应 unwrap 策略
+
+集成平台统一响应:`{ result, error, error_description, data? }`。
+
+- `result=true` → `McR.success(data)`
+- `result=false` → `McR.error(error, error_description)`
+- HTTP 非 2xx → `McException.throwBy(...)` 由 `CatchException` 统一拦截
+
+不在 Client 层抛业务异常(保留 `McR` 让调用方决定如何处理"用户不存在/已删除"等边界)。
+
+### D5. 配置段命名
+
+`application.yml` 增加 `integration:` 段,与 `dingtalk:` `aliwork:` 平级:
+
+```yaml
+integration:
+  baseUrl: https://iam.example.com    # 集成平台域名(不含路径前缀)
+  clientId: ${INTP_CLIENT_ID:}
+  clientSecret: ${INTP_CLIENT_SECRET:}
+```
+
+`baseUrl` 不含 `/iam/` 前缀,由 Client 内部拼接,便于将来扩到 `/acm/` 域。
+
+### D6. 不做的事(明确 yagni 边界)
+
+- ❌ 高阶 Service 封装(用户明确"不需要",调用方自己组合)
+- ❌ 多租户(每租户一套凭据) —— 留待 mjava-pro `DynamicIntpService` 后续专项
+- ❌ Webhook 接收 `/iam/api/open/event/...` —— 不在用户域内
+- ❌ 单元测试 / 集成冒烟 —— 阻塞在 Maven,后续阶段做
+
+## 风险
+
+1. **apifox 文档与生产 API 漂移**:apifox 是文档站,与实际生产环境是否一致需联调验证。建议接入第一个客户时立即抓真实 access_token 跑一次 createUser。
+2. **`/iam/api/user` 单数 vs `/iam/api/users` 复数**:修改用户 endpoint 是 `/iam/api/user`(单数),与其它 CRUD 不一致。已按文档原样实现,**实施冒烟时需复核**。
+3. **`updateUser` 的 `username` 是 Query 而非 Path**:与 RESTful 惯例不同,不要写成 `/iam/api/user/{username}`。
+4. **`deleteUsers` 用 POST 而非 DELETE**:因为 body 要带数组;按文档原样保留。
+5. **token 失效处理**:本期仅做"过期前刷新"。如果服务端单边失效(管理员 revoke),调用会拿到 401,本期不做"401 自动重拿"重试链,由调用方处理。

+ 37 - 0
openspec/changes/add-integration-user-api/proposal.md

@@ -0,0 +1,37 @@
+> 状态(2026-04-26 立项):**规则定义阶段**
+> 优先级:中(Phase B.1 第三个 vendor 专项)
+> 依赖:基座 `UtilHttp` / `UtilToken` / `McR` 已就绪
+
+## Why
+
+后续多个客户项目要调用「集成平台」(IAM / 身份治理产品)的用户管理接口,做"钉钉用户 → 集成平台用户"的同步与对接。集成平台官方接口为 RESTful + OAuth2 client_credentials 模式,与 mjava 现有 vendor(dingtalk/aliwork/...)模式不同,需要:
+
+- 独立的 `service/integration/` 包承载;类前缀 `IntpClient`
+- 独立的 `IntpConf`(baseUrl / clientId / clientSecret),配置段 `integration.*` 与 dingtalk/aliwork 平级
+- token 走 `UtilToken` 缓存,按"过期前刷"策略(OAuth2 返回 `expires_in`)
+
+本 change **只覆盖用户域写操作 + 鉴权**:getAccessToken / createUser / updateUser / deleteUser。其它能力(org、角色、查询、Webhook、连接器事件)按需另立 change。
+
+## What Changes
+
+新增 `service/integration/` 包,含:
+
+- `IntpConf`:配置类(baseUrl / clientId / clientSecret)
+- `IntpClient`(接口):5 个原子方法
+  - `getAccessToken()` → 内部调用,不暴露给业务方
+  - `createUser(accessToken, username, password, body_ext)`
+  - `updateUser(accessToken, username, body_ext)`
+  - `deleteUsers(accessToken, usernames)`
+  - `queryUsers(accessToken, query)` —— 顺手对齐已有 GET 接口,便于校验
+- `IntpClientImpl`:基于 `UtilHttp` + `UtilToken` 的实现
+- 三个客户子模块的 `application-{dev,prod}.yml.example` 加 `integration:` 段占位
+
+不涉及:
+- mjava-pro 多租户化(按用户决策"基座单租户")
+- Service 层业务封装(按用户决策"不需要")
+- 其它 18 个非用户接口(org / 角色 / Webhook / 连接器)
+
+## Capabilities
+
+### New Capabilities
+- `integration-user-api`:集成平台用户域的鉴权与 CRUD 原子接口集

+ 85 - 0
openspec/changes/add-integration-user-api/specs/integration-user-api/spec.md

@@ -0,0 +1,85 @@
+## ADDED Requirements
+
+### Requirement: 集成平台 OAuth2 鉴权与 token 缓存
+
+`IntpClientImpl` SHALL 通过 `client_credentials` 流程获取 access_token,并使用 `UtilToken` 缓存。所有对外原子方法 MUST 接收 `accessToken` 作为第一个参数(按 baseline §3.4.2)。
+
+#### Scenario: 首次拉取 access_token
+
+- **WHEN** `UtilToken` 缓存中无 `integration:accessToken`
+- **THEN** 向 `{baseUrl}/iam/token` 发 POST,body 为 `application/x-www-form-urlencoded`,字段:`grant_type=client_credentials` / `client_id={IntpConf.clientId}` / `client_secret={IntpConf.clientSecret}`
+- **AND** 响应 `access_token` 写入缓存,TTL = `expires_in - 60` 秒
+
+#### Scenario: 缓存命中
+
+- **WHEN** `UtilToken` 缓存中已有未过期的 `integration:accessToken`
+- **THEN** 直接复用,不发 HTTP 请求
+
+#### Scenario: token 失效
+
+- **WHEN** 服务端 revoke 或过期
+- **THEN** 调用方收到 HTTP 401 + `error/error_description`
+- **AND** 本期不做"401 自动重拿",由调用方决定重试策略
+
+### Requirement: 用户创建
+
+`IntpClient.createUser` SHALL 对齐 apifox 接口 `POST /iam/api/users`。必填字段必须显式入参;可选字段通过 `body_ext` Map 注入。
+
+#### Scenario: 创建用户(最小集)
+
+- **WHEN** 调用 `createUser(accessToken, "alice", "Pwd@123", null)`
+- **THEN** 向 `{baseUrl}/iam/api/users` 发 POST `application/json`
+- **AND** Header `Authorization: Bearer {accessToken}`
+- **AND** body 为 `{"username":"alice","password":"Pwd@123"}`
+
+#### Scenario: 创建用户(完整字段)
+
+- **WHEN** `body_ext` 含 `name` / `email` / `phone_number` / `user_job_number` / `nick_name` / `picture` / `org_ids` / `address` / `title` / `hired_date` / `tag` / `group_positions`
+- **THEN** body 合并 `username` / `password` 与 `body_ext` 全部字段
+- **AND** javadoc MUST 枚举上述字段名 + 类型 + 含义
+
+#### Scenario: 创建失败
+
+- **WHEN** 服务端返回 `{"result":false,"error":"USER_EXIST","error_description":"用户已存在"}`
+- **THEN** 返回 `McR.error("USER_EXIST", "用户已存在")`
+
+### Requirement: 用户修改
+
+`IntpClient.updateUser` SHALL 对齐 apifox 接口 `PATCH /iam/api/user`。`username` 通过 Query Param 传递(**非 Path**),其它字段全部走 body。
+
+#### Scenario: 修改单字段
+
+- **WHEN** 调用 `updateUser(accessToken, "alice", Map.of("email", "alice@new.com"))`
+- **THEN** 向 `{baseUrl}/iam/api/user?username=alice` 发 PATCH
+- **AND** body 为 `{"email":"alice@new.com"}`
+- **AND** 不传的字段保持服务端原值不变
+
+### Requirement: 用户删除(批量)
+
+`IntpClient.deleteUsers` SHALL 对齐 apifox 接口 `POST /iam/api/users/delete`。**HTTP 方法是 POST**,因 body 需带 username 数组。
+
+#### Scenario: 批量按登录名删除
+
+- **WHEN** 调用 `deleteUsers(accessToken, List.of("alice","bob"))`
+- **THEN** 向 `{baseUrl}/iam/api/users/delete` 发 POST `application/json`
+- **AND** body 为 `{"usernames":["alice","bob"]}`
+
+### Requirement: 用户查询
+
+`IntpClient.queryUsers` SHALL 对齐 apifox 接口 `GET /iam/api/users`,支持分页与多维过滤。
+
+#### Scenario: 关键字搜索
+
+- **WHEN** 调用 `queryUsers(accessToken, Map.of("q","alice","page",1,"size",20))`
+- **THEN** 向 `{baseUrl}/iam/api/users?q=alice&page=1&size=20` 发 GET
+- **AND** 解析响应 `data.items[]` 返回 `McR.success(data)`
+
+### Requirement: 配置段独立命名
+
+`IntpConf` SHALL 通过 Spring `@ConfigurationProperties("integration")` 加载配置;`baseUrl` 不含 `/iam` 前缀。
+
+#### Scenario: 三个客户子模块的 yml 模板
+
+- **WHEN** 浏览 `mjava-mcli` / `mjava-shunfeng` / `mjava-guangming` 的 `application-{dev,prod}.yml.example`
+- **THEN** 每个模板 MUST 含 `integration:` 段示例,字段:`baseUrl` / `clientId` / `clientSecret`
+- **AND** 真实凭据从 `${INTP_CLIENT_ID}` / `${INTP_CLIENT_SECRET}` 环境变量注入

+ 48 - 0
openspec/changes/add-integration-user-api/tasks.md

@@ -0,0 +1,48 @@
+## 0. 文档抓取(前置)
+
+> 已完成:apifox 公开站抓 5 个接口字段详情,结果固化在 `design.md` 覆盖度矩阵 + 风险章节。
+> 风险:apifox 文档可能与生产 API 漂移,实施冒烟时需联调验证。
+
+## 1. 配置类与 yml 段
+
+- [ ] 1.1 新建 `mjava/src/main/java/com/malk/service/integration/IntpConf.java`,`@ConfigurationProperties("integration")`,字段 `baseUrl` / `clientId` / `clientSecret`
+- [ ] 1.2 `mjava/src/main/resources/application-prod.yml.example` 加 `integration:` 段
+- [ ] 1.3 `mjava-mcli` / `mjava-shunfeng` / `mjava-guangming` 三个 `application-prod.yml.example` 加 `integration:` 段
+- [ ] 1.4 三个客户已有 `application-dev.yml`(不是 .example)按需自行加段,本 change 不动真实 yml
+
+## 2. Client 接口与实现
+
+- [ ] 2.1 新建 `service/integration/IntpClient.java`(接口),方法:
+      - `McR createUser(String accessToken, String username, String password, Map<String,Object> body_ext)`
+      - `McR updateUser(String accessToken, String username, Map<String,Object> body_ext)`
+      - `McR deleteUsers(String accessToken, List<String> usernames)`
+      - `McR queryUsers(String accessToken, Map<String,Object> query)`
+- [ ] 2.2 javadoc 完整枚举 `body_ext` 与 `query` 的可选字段(见 design.md / spec.md)
+- [ ] 2.3 新建 `service/integration/impl/IntpClientImpl.java`,依赖 `UtilHttp` + `UtilToken` + `IntpConf`
+- [ ] 2.4 实现内部 `getAccessToken()`:缓存命中直接返回;miss 调 `POST {baseUrl}/iam/token` 走 form body 拿 token,TTL=`expires_in - 60`
+- [ ] 2.5 4 个对外方法实现:拼 URL + Header `Authorization: Bearer {accessToken}` + 调 `UtilHttp.doPost/doPatch/doGet` + unwrap `{result, error, error_description, data}` → `McR`
+
+## 3. 验证
+
+- [ ] 3.1 `mvn -pl mjava -am clean compile` 通过
+- [ ] 3.2 `mvn -pl mjava-mcli -am clean package -Dmaven.test.skip=true` 通过(确保配置类不会让客户子模块启动失败)
+- [ ] 3.3 联调冒烟(任选一个客户环境):
+      - 拿 clientId/clientSecret 配进 dev yml
+      - 写一个临时 Controller 调 `createUser("test_user", "Pwd@123", null)`
+      - 验证服务端真实创建一条记录 + 返回数据正确
+      - 重复调用验证 token 缓存命中(不再发 `/iam/token` 请求)
+- [ ] 3.4 `openspec validate add-integration-user-api --strict` 通过
+
+## 4. 归档
+
+- [ ] 4.1 `/opsx:archive add-integration-user-api` → 移到 `openspec/changes/archive/2026-04-XX-add-integration-user-api/`
+- [ ] 4.2 spec 合并到 `openspec/specs/integration-user-api/`
+- [ ] 4.3 BACKLOG.md 标 ✅;mjava-baseline.md vendor 表加一行 integration
+
+## 5. 后续 change(不在本期范围)
+
+- 集成平台 org / 部门管理(`/iam/api/orgs/*`)
+- 集成平台 角色管理(`/iam/api/tags/*`)
+- 集成平台 启用/禁用用户 + 改密码(`/iam/api/users/status` / `/iam/api/users/password`)
+- Webhook 接收(`/iam/api/open/event/...`)
+- mjava-pro 多租户 `DynamicIntpService`