docs(g4): uniservice open/v1/users API contract + implementation plan
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# 用户信息开放接口文档(/open/v1/users)
|
||||
|
||||
> 面向外部系统对接 · 2026-09-22 上线 · 服务方:uniservice(合塑/新友合集团统一后台)
|
||||
|
||||
## 1. 基本信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 生产地址 | `https://uniservice.server.hnxinyouhe.com/open/v1/users` |
|
||||
| 方法 | `GET` |
|
||||
| 鉴权 | Header `X-Api-Key`(独立钥匙,遗失/泄露请联系服务方重发) |
|
||||
| 交互风格 | 标准 HTTP 状态码 + JSON;文档另见服务方 `/docs`(OpenAPI) |
|
||||
|
||||
## 2. 请求参数(Query)
|
||||
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `page` | int | 1 | 页码,<1 归一为 1 |
|
||||
| `page_size` | int | 20 | 每页条数,<1 或 >200 归一为 20 |
|
||||
| `company_id` | int | - | 按公司 id 精确筛选 |
|
||||
| `status` | string | `"1"` | `1`=在职(默认)/ `2`=离职 / `0`=停用 / `all`=全部 |
|
||||
| `keyword` | string | - | 姓名 / 邮箱 / 手机 模糊匹配(`%` `_` 会按字面匹配) |
|
||||
| `ids` | string | - | 用户 id 逗号分隔精确拉取,如 `9,10`;非法项自动忽略 |
|
||||
|
||||
## 3. 响应
|
||||
|
||||
### 成功(HTTP 200)
|
||||
|
||||
```json
|
||||
{"success": true, "message": "ok",
|
||||
"data": {"items": [ /* 用户对象数组 */ ],
|
||||
"total": 213, "page": 1, "page_size": 20}}
|
||||
```
|
||||
|
||||
### 用户对象字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 用户 id(**稳定主键**) |
|
||||
| `sn` | string | 工号 |
|
||||
| `name` | string | 姓名 |
|
||||
| `sex` | int/null | 性别 |
|
||||
| `email` | string | 企业邮箱 |
|
||||
| `phone` | string | 手机号 |
|
||||
| `avatar` | string/null | 头像 URL(飞书 CDN 直链,可直接展示) |
|
||||
| `status` | int | 1=在职 / 2=离职 / 0=停用 |
|
||||
| `user_type` | int | 用户类型 |
|
||||
| `manager_id` | int/null | 直属上级用户 id |
|
||||
| `company_id` | int | 所属公司 id(见下方"一人多档"说明) |
|
||||
| `union_id` | string/null | **飞书 union_id**(同开发商体系内跨应用稳定) |
|
||||
| `created_at` / `updated_at` | string | 档案时间 |
|
||||
| `company_name` | string | 公司名称(关联) |
|
||||
| `company_abbr` | string | 公司简称 |
|
||||
| `company_tenant_key` | string | 公司飞书 tenant_key(关联) |
|
||||
| `feishu_open_id` | string/null | 飞书 open_id(**应用域**,见 §5 注意①) |
|
||||
| `feishu_user_id` | string/null | 飞书 user_id |
|
||||
| `feishu_tenant_key` | string/null | 绑定应用的 tenant_key |
|
||||
| `last_login_at` | string/null | 该飞书绑定最近登录时间 |
|
||||
| `roles` | string[] | 角色 code 列表(如 `["user","admin"]`) |
|
||||
|
||||
### 失败
|
||||
|
||||
| HTTP | 响应 | 场景 |
|
||||
|---|---|---|
|
||||
| 401 | `{"success":false,"message":"Invalid API Key","error_code":"INVALID_API_KEY"}` | key 缺失/错误/未配置 |
|
||||
| 400 | `{"success":false,"message":"查询失败","error_code":"QUERY_FAIL"}` | 服务端查询异常 |
|
||||
|
||||
## 4. 调用示例
|
||||
|
||||
```bash
|
||||
# 基本拉取(默认在职第一页)
|
||||
curl -H "X-Api-Key: <你的key>" \
|
||||
"https://uniservice.server.hnxinyouhe.com/open/v1/users?page=1&page_size=50"
|
||||
|
||||
# 按公司 + 关键词
|
||||
curl -G -H "X-Api-Key: <你的key>" \
|
||||
"https://uniservice.server.hnxinyouhe.com/open/v1/users" \
|
||||
--data-urlencode "company_id=1" --data-urlencode "keyword=张"
|
||||
|
||||
# 精确拉一批人(含离职)
|
||||
curl -H "X-Api-Key: <你的key>" \
|
||||
"https://uniservice.server.hnxinyouhe.com/open/v1/users?status=all&ids=9,10,74"
|
||||
```
|
||||
|
||||
## 5. 重要注意事项(对接飞书登录必读)
|
||||
|
||||
1. **`feishu_open_id` 是应用域的**:它是用户在**服务方飞书应用**里的 open_id。
|
||||
贵方若使用**自己另建的飞书应用**,此 id 在贵方应用内查不到人,请改用:
|
||||
- `union_id`(仅当双方应用属**同一飞书开发商**时跨应用一致);或
|
||||
- `email` / `phone` 匹配(登录后用 contact API 反查)。
|
||||
若贵方直接复用服务方的飞书应用凭证,则 `feishu_open_id` 可直接用。
|
||||
2. **一人多档**:本系统用户档案**按公司分档**——同一自然人在不同公司各有一行
|
||||
(不同 `id`/`sn`,`company_id` 不同)。归并自然人请用 `union_id`(或手机号),
|
||||
不要假设姓名唯一。
|
||||
3. **未绑定飞书**的用户 `feishu_*` 三字段为 `null`(`union_id` 亦可能为 null)。
|
||||
4. 分页上限 200/页;全量同步建议按 `page` 递增循环至取空。
|
||||
5. key 是贵方系统专属,请勿嵌入前端/客户端代码,仅服务端持有。
|
||||
|
||||
## 6. 变更约定
|
||||
|
||||
接口属 v1 冻结面:**只增字段不改既有字段语义**(新增字段贵方可忽略)。
|
||||
不兼容变更将升 v2 另立路径。字段需求变化联系服务方评估。
|
||||
@@ -0,0 +1,739 @@
|
||||
# G4 用户接口对接(uniservice /open/v1/users)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 P4a 的 mock 用户源替换为真实的 uniservice 统一后台用户接口(G4 关口闭环),同时落地 V1.1 初始凭据决策(新建用户直接 active、账号=手机号、初始密码=后 6 位,全局生效含 mock)。
|
||||
|
||||
**Architecture:** 引擎/端口/表结构零契约变更,仅新增 `HttpUserSource` 适配器 + 模块 env 开关装配(`USER_SYNC_SOURCE=mock|http`,缺省 mock 保 e2e 不变)。因上游 API **无增量过滤参数**,HTTP 源采用「每批全量拉取 + 引擎行级版本比较幂等」策略;一人多档按 phone **全局**去重(跨上游页)。`external_user_mappings.sourceSystem` 随源切换(mock=`'mock'`、http=`'uniservice'`),端口新增 `sourceSystem` 只读属性。
|
||||
|
||||
**Tech Stack:** NestJS(DI provider 切换)、Node 22 原生 fetch(AbortSignal.timeout)、jest(单测 mock fetch)+ 现有 e2e(MySQL + cn Redis)。
|
||||
|
||||
**背景(半成品现状):** 工作区已有未提交改动:接口文档 `docs/open-v1-users-api.md`、`http-user-source.ts` 草稿(有 bug,本计划重写)、引擎 V1.1 行为变更(已改完但 e2e 未跟上)、env 示例两个变量。用户已拍板:**V1.1 全局生效,重写 e2e**。
|
||||
|
||||
---
|
||||
|
||||
## 执行注意(红线)
|
||||
|
||||
- **不碰**:openapi.yaml / packages/contracts / outbox·sync_inbox·sync_state 同步账本 / users 表结构(不加列)/ main-ov.ts(用户同步是 cn 单域,ov 入口不装配)。
|
||||
- 引擎红线 1-4 全部维持:同步绝不写 password_hash(新建除外,见 V1.1)→ 关联路径仍绝不动既有凭据/角色/会话;空响应/部分页绝不停用;上游恢复不解除 localLocked;(sourceSystem, externalUserId) 稳定锚点。
|
||||
- e2e 是守护网:`user-sync.e2e-spec.ts` ①⑤⑪ 按 V1.1 重写是**产品决策落地**,不是放松断言——新断言(同步即可用初始密码登录)强度不低于旧断言。
|
||||
- 本地跑 e2e 需 MySQL 8.4 + Redis(cn)就绪(项目标准开发环境);Flutter 相关命令才需要 `source scripts/flutter-env.sh`,本计划不涉及。
|
||||
- 提交信息用 conventional commits,末尾加 `Co-Authored-By: Claude Code <noreply@anthropic.com>`。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `services/api/src/user-sync/user-source.port.ts` | 修改 | 端口新增 `readonly sourceSystem: string` |
|
||||
| `services/api/src/user-sync/mock-user-source.ts` | 修改 | 实现 `sourceSystem = 'mock'` |
|
||||
| `services/api/src/user-sync/user-sync.service.ts` | 修改 | 删哨兵死代码;sourceSystem 改从端口取;文件头注释重写(V1.1) |
|
||||
| `services/api/src/user-sync/http-user-source.ts` | 重写 | 真实源适配器:全量拉取 + 全局去重 + 分块续翻 |
|
||||
| `services/api/src/user-sync/user-sync.module.ts` | 修改 | env 开关装配 mock/http |
|
||||
| `services/api/src/auth/auth.service.ts` | 修改 | 仅注释微调(哨兵哈希历史化) |
|
||||
| `services/api/test/user-sync.e2e-spec.ts` | 修改 | ①⑤⑪ + 文件头按 V1.1 重写 |
|
||||
| `services/api/test/http-user-source.spec.ts` | 新建 | 适配器单测(mock fetch) |
|
||||
| `.env.example` / `.env.production.example-cn` | 修改 | 补 `USER_SYNC_SOURCE` |
|
||||
| `docs/plans/progress/G4-用户接口对接-验收记录.md` | 新建 | 验收记录 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: e2e 按 V1.1 语义重写(①⑤⑪ + 文件头)
|
||||
|
||||
引擎的 V1.1 行为变更已在工作区改完(新建/重建路径均 `credentialStatus: 'active'` + 账号后 6 位初始密码),本任务让守护网追上语义。
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/test/user-sync.e2e-spec.ts`
|
||||
|
||||
- [ ] **Step 1: 更新文件头覆盖说明(第 5-7 行、25-26 行)**
|
||||
|
||||
第 6-7 行 `// 哨兵哈希 !:pending:);counts/水位/映射行;` 所在块改为:
|
||||
|
||||
```ts
|
||||
// ① 首轮全量:3 用户 = 1 account 关联(本地直建账号建映射,不动其
|
||||
// 凭据/角色,会话保留)+ 2 新增(V1.1:credentialStatus=active +
|
||||
// 初始密码=账号后 6 位,可立即登录);counts/水位/映射行;
|
||||
```
|
||||
|
||||
第 25-26 行改为:
|
||||
|
||||
```ts
|
||||
// ⑪ V1.1 初始凭据闭环:同步即可用初始密码登录 → activate 幂等 →
|
||||
// reset-password 轮换后初始密码失效(Task 6 端点联动);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 ① 的新增用户断言(第 269-277 行)**
|
||||
|
||||
```ts
|
||||
// 新增(V1.1 初始凭据):直接 active + 初始密码=账号后 6 位(非哨兵哈希)。
|
||||
for (const account of ['sync-u2', 'sync-u3']) {
|
||||
const user = await userByAccount(account);
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.credentialStatus).toBe('active');
|
||||
expect(user?.role).toBe('staff');
|
||||
expect(user?.sourceStatus).toBe('active');
|
||||
expect(user?.passwordHash.startsWith('!:pending:')).toBe(false);
|
||||
}
|
||||
// V1.1:同步完成即可用初始密码登录。
|
||||
const initialLogin = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||
expect(initialLogin.status).toBe(200);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修正 ⑤ 的第 4 用户断言(第 416 行)**
|
||||
|
||||
`expect(u4?.credentialStatus).toBe('pending');` → `expect(u4?.credentialStatus).toBe('active');`
|
||||
|
||||
- [ ] **Step 4: 重写 ⑪ 整个用例(第 603-634 行)**
|
||||
|
||||
```ts
|
||||
it('⑪ V1.1 初始凭据闭环:同步即可登录 → activate 幂等 → reset-password 轮换初始密码失效', async () => {
|
||||
const u2 = await userByAccount('sync-u2');
|
||||
expect(u2?.credentialStatus).toBe('active');
|
||||
|
||||
// 初始密码 = 账号后 6 位:同步完成即可登录(V1.1 取代旧的哨兵待激活闭环)。
|
||||
const initial = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||
expect(initial.status).toBe(200);
|
||||
expect(typeof initial.body.sessionToken).toBe('string');
|
||||
|
||||
// activate 对 active 用户幂等(200,不重复落审计——admin-users 语义)。
|
||||
const activate = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/admin/users/${u2?.id}/activate`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({});
|
||||
expect(activate.status).toBe(200);
|
||||
expect(activate.body.credentialStatus).toBe('active');
|
||||
|
||||
// 管理员 reset-password 轮换:新凭据可登录,初始密码同步失效。
|
||||
const reset = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/admin/users/${u2?.id}/reset-password`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({});
|
||||
expect(reset.status).toBe(200);
|
||||
const rotated = reset.body.newPassword as string;
|
||||
expect(rotated.length).toBeGreaterThanOrEqual(12);
|
||||
const ok = await login('sync-u2', rotated);
|
||||
expect(ok.status).toBe(200);
|
||||
const old = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||
expect(old.status).toBe(401);
|
||||
expect(old.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑 user-sync e2e 验证 12 用例全绿**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test:e2e -- user-sync.e2e-spec`
|
||||
Expected: 12 passed, 0 failed
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/test/user-sync.e2e-spec.ts
|
||||
git commit -m "test(api): user-sync e2e aligned to V1.1 initial credentials
|
||||
|
||||
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 端口 sourceSystem + 引擎清理(V1.1 注释、删哨兵死代码)
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/src/user-sync/user-source.port.ts`
|
||||
- Modify: `services/api/src/user-sync/mock-user-source.ts`
|
||||
- Modify: `services/api/src/user-sync/user-sync.service.ts`
|
||||
- Modify: `services/api/src/auth/auth.service.ts`(仅注释)
|
||||
|
||||
- [ ] **Step 1: 端口加 sourceSystem(user-source.port.ts)**
|
||||
|
||||
`UserSourcePort` 接口内(`listPage` 声明之前)加:
|
||||
|
||||
```ts
|
||||
/** 本源在 external_user_mappings.sourceSystem 的命名空间标识——映射锚点
|
||||
* (sourceSystem, externalUserId) 的组成部分(引擎红线 4)。mock='mock',
|
||||
* uniservice 真实源='uniservice'(http-user-source.ts)。 */
|
||||
readonly sourceSystem: string;
|
||||
```
|
||||
|
||||
文件头第 3 行 `(P4a Task 7,A29;G4:真实接口到位后仅替换 provider 实现,引擎/契约不变)` 保持(语义已兑现)。
|
||||
|
||||
- [ ] **Step 2: mock 实现该属性(mock-user-source.ts)**
|
||||
|
||||
`MockUserSource` 类内(`scenario` 字段声明之前)加:
|
||||
|
||||
```ts
|
||||
/** 端口 sourceSystem:mock 源命名空间(e2e 断言 'mock:ext-*' 字面量)。 */
|
||||
readonly sourceSystem = 'mock';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 引擎改造(user-sync.service.ts)**
|
||||
|
||||
1. 删除 `export const USER_SYNC_SOURCE_SYSTEM = 'mock';`(第 54-55 行)及其注释。
|
||||
2. 删除 `export const PENDING_PASSWORD_HASH_PREFIX = '!:pending:';`(第 57-58 行)与 `pendingPasswordHash()` 函数(第 90-92 行)。`randomBytes` import 保留(`generateRunId` 仍用)。
|
||||
3. 新增 helper(放在 `generateRunId` 旁):
|
||||
|
||||
```ts
|
||||
/** V1.1 初始口令:登录账号(手机号)后 6 位;缺失/空回退 '000000'。 */
|
||||
function initialPassword(loginAccount: string | undefined): string {
|
||||
return loginAccount?.slice(-6) || '000000';
|
||||
}
|
||||
```
|
||||
|
||||
4. `applyItem` 内三处 `sourceSystem: USER_SYNC_SOURCE_SYSTEM`(第 366、449、486 行)改为在 try 块顶部取 `const sourceSystem = this.source.sourceSystem;` 后引用。
|
||||
5. 两处建用户 `passwordHash: await hashPassword((item.loginAccount ?? '').slice(-6) || '000000')` 改为 `passwordHash: await hashPassword(initialPassword(item.loginAccount))`。
|
||||
6. 文件头重写:第 1-2 行改为:
|
||||
|
||||
```ts
|
||||
// services/api/src/user-sync/user-sync.service.ts —— 用户同步引擎(P4a Task 7,
|
||||
// A29;G4 后双源:USER_SYNC_SOURCE 装配 mock 或 http——引擎/契约不变)。
|
||||
```
|
||||
|
||||
第 26-29 行「待激活闭环」段替换为:
|
||||
|
||||
```ts
|
||||
// 初始凭据(V1.1,2026-09-22 G4 真实接口上线):同步新建用户直接
|
||||
// credentialStatus=active、初始口令=登录账号(手机号)后 6 位(argon2 哈希,
|
||||
// 不足 6 位回退 '000000');管理员可经 reset-password 轮换。P4a 的「待激活 +
|
||||
// 哨兵哈希」闭环退役(auth.service 对非法哈希的等代价 dummy verify 防御保留,
|
||||
// 兼容历史行)。account 关联路径仍绝不动既有凭据(红线 1 不变)。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: auth.service.ts 第 135 行注释历史化**
|
||||
|
||||
`// 哨兵哈希(P4a Task 7:同步创建的待激活账号 passwordHash='!:pending:…')`
|
||||
改为:
|
||||
`// 历史哨兵哈希行(P4a 待激活已退役;非法 argon2 编码统一按密码错误处理)`
|
||||
|
||||
- [ ] **Step 5: typecheck + e2e 回归**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn typecheck` → Expected: 0 error
|
||||
Run: `pnpm --filter @photo/api-cn test:e2e -- user-sync.e2e-spec` → Expected: 12 passed
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/src/user-sync/user-source.port.ts services/api/src/user-sync/mock-user-source.ts services/api/src/user-sync/user-sync.service.ts services/api/src/auth/auth.service.ts
|
||||
git commit -m "refactor(api): per-source sourceSystem on user source port, retire pending-activation dead code
|
||||
|
||||
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: HttpUserSource 重写(TDD)
|
||||
|
||||
草稿的三个 bug 全部在此修:①数值水位被当页码;②一人多档去重只在页内;③类型不对齐端口。
|
||||
|
||||
**Files:**
|
||||
- Create: `services/api/test/http-user-source.spec.ts`
|
||||
- Rewrite: `services/api/src/user-sync/http-user-source.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败单测(完整文件)**
|
||||
|
||||
```ts
|
||||
// services/api/test/http-user-source.spec.ts —— HttpUserSource 单测(G4 对接)。
|
||||
// 覆盖:跨页一人多档全局去重、数值水位游标不当页码(回归)、分块续翻 p:N、
|
||||
// 鉴权/失败路径、无手机号跳过。fetch 全程 mock,不发真实请求。
|
||||
import { HttpUserSource } from '../src/user-sync/http-user-source';
|
||||
|
||||
interface OpenV1User {
|
||||
id: number;
|
||||
sn: string;
|
||||
name: string;
|
||||
sex: number | null;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string | null;
|
||||
status: number;
|
||||
user_type: number;
|
||||
manager_id: number | null;
|
||||
company_id: number;
|
||||
union_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
company_name: string;
|
||||
company_abbr: string;
|
||||
company_tenant_key: string;
|
||||
feishu_open_id: string | null;
|
||||
feishu_user_id: string | null;
|
||||
feishu_tenant_key: string | null;
|
||||
last_login_at: string | null;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const BASE_USER: OpenV1User = {
|
||||
id: 1, sn: 'S1', name: '张三', sex: null, email: 'z@example.com',
|
||||
phone: '13800000001', avatar: null, status: 1, user_type: 1, manager_id: null,
|
||||
company_id: 1, union_id: null, created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-20T00:00:00Z', company_name: 'X公司', company_abbr: 'X',
|
||||
company_tenant_key: 'tk', feishu_open_id: null, feishu_user_id: null,
|
||||
feishu_tenant_key: null, last_login_at: null, roles: ['user'],
|
||||
};
|
||||
|
||||
const user = (over: Partial<OpenV1User>): OpenV1User => ({ ...BASE_USER, ...over });
|
||||
|
||||
const okBody = (items: OpenV1User[], total: number, page = 1, pageSize = 200) => ({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
data: { items, total, page, page_size: pageSize },
|
||||
});
|
||||
|
||||
/** 最小 fetch Response 替身(仅适配器用到的字段)。 */
|
||||
function fakeRes(status: number, body: unknown) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalEnv = {
|
||||
url: process.env.OPEN_API_USERS_URL,
|
||||
key: process.env.OPEN_API_USERS_KEY,
|
||||
};
|
||||
|
||||
describe('HttpUserSource(uniservice /open/v1/users 适配器)', () => {
|
||||
const URL = 'https://uniservice.example/open/v1/users';
|
||||
let source: HttpUserSource;
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OPEN_API_USERS_URL = URL;
|
||||
process.env.OPEN_API_USERS_KEY = 'test-key';
|
||||
source = new HttpUserSource();
|
||||
fetchMock = jest.fn();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
// 保存/恢复而非删除——避免清掉开发者本地 .env 装载的同名值。
|
||||
if (originalEnv.url === undefined) {
|
||||
delete process.env.OPEN_API_USERS_URL;
|
||||
} else {
|
||||
process.env.OPEN_API_USERS_URL = originalEnv.url;
|
||||
}
|
||||
if (originalEnv.key === undefined) {
|
||||
delete process.env.OPEN_API_USERS_KEY;
|
||||
} else {
|
||||
process.env.OPEN_API_USERS_KEY = originalEnv.key;
|
||||
}
|
||||
});
|
||||
|
||||
it('跨上游页的一人多档按 phone 全局去重,保留 updated_at 最新行;字段映射正确', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(fakeRes(200, okBody([user({ id: 1, name: '张三新档', updated_at: '2026-09-21T00:00:00Z' })], 3)))
|
||||
.mockResolvedValueOnce(fakeRes(200, okBody([
|
||||
user({ id: 2, name: '张三旧档', phone: '13800000001', status: 2, updated_at: '2026-09-19T00:00:00Z' }),
|
||||
user({ id: 3, name: '李四', phone: '13800000002', sn: 'S3', updated_at: '2026-09-18T00:00:00Z' }),
|
||||
], 3, 2)));
|
||||
|
||||
const page = await source.listPage();
|
||||
|
||||
// 请求形状:page=1 起、page_size=200、status=all、X-Api-Key 头。
|
||||
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(firstUrl.searchParams.get('page')).toBe('1');
|
||||
expect(firstUrl.searchParams.get('page_size')).toBe('200');
|
||||
expect(firstUrl.searchParams.get('status')).toBe('all');
|
||||
expect((fetchMock.mock.calls[0][1] as { headers: Record<string, string> }).headers['X-Api-Key']).toBe('test-key');
|
||||
|
||||
// 去重后 2 人;13800000001 保留 id=1(updated_at 最新),status=1 → active。
|
||||
expect(page.items).toHaveLength(2);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
const kept = page.items.find((i) => i.loginAccount === '13800000001');
|
||||
expect(kept).toMatchObject({
|
||||
externalUserId: '1',
|
||||
displayName: '张三新档',
|
||||
sourceStatus: 'active',
|
||||
sourceVersion: Math.floor(Date.parse('2026-09-21T00:00:00Z') / 1000),
|
||||
});
|
||||
expect(kept?.updatedAt).toEqual(new Date('2026-09-21T00:00:00Z'));
|
||||
expect(page.items.find((i) => i.externalUserId === '2')).toBeUndefined();
|
||||
expect(source.sourceSystem).toBe('uniservice');
|
||||
});
|
||||
|
||||
it('数值水位游标不当页码:cursor="1758520000" 仍从第 1 页全量拉取(回归)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(fakeRes(200, okBody([user()], 1)));
|
||||
|
||||
const page = await source.listPage('1758520000');
|
||||
|
||||
const url = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get('page')).toBe('1');
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('分块续翻:>200 行切两块,p:2 用缓存不再回源;新批(水位/空 cursor)重新全量拉', async () => {
|
||||
const many = Array.from({ length: 205 }, (_, i) =>
|
||||
user({ id: i + 1, phone: `1380000${String(i).padStart(4, '0')}` }),
|
||||
);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(fakeRes(200, okBody(many.slice(0, 200), 205)))
|
||||
.mockResolvedValueOnce(fakeRes(200, okBody(many.slice(200), 205, 2)));
|
||||
|
||||
const first = await source.listPage();
|
||||
expect(first.items).toHaveLength(200);
|
||||
expect(first.nextCursor).toBe('p:2');
|
||||
|
||||
const second = await source.listPage('p:2');
|
||||
expect(second.items).toHaveLength(5);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // 续翻走缓存
|
||||
|
||||
// 新一批(引擎传数值水位)→ 重新全量拉取。
|
||||
fetchMock.mockResolvedValueOnce(fakeRes(200, okBody([], 0)));
|
||||
const fresh = await source.listPage('999');
|
||||
expect(fresh.items).toHaveLength(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('失败路径:401 / HTTP 500 / success=false / env 缺失各自抛错', async () => {
|
||||
fetchMock.mockResolvedValueOnce(fakeRes(401, { success: false, message: 'Invalid API Key', error_code: 'INVALID_API_KEY' }));
|
||||
await expect(source.listPage()).rejects.toThrow(/Invalid API Key|鉴权失败/);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(fakeRes(500, { success: false, message: 'boom' }));
|
||||
await expect(source.listPage()).rejects.toThrow(/HTTP 500/);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(fakeRes(200, { success: false, message: '查询失败' }));
|
||||
await expect(source.listPage()).rejects.toThrow('查询失败');
|
||||
|
||||
delete process.env.OPEN_API_USERS_KEY;
|
||||
await expect(source.listPage()).rejects.toThrow(/OPEN_API_USERS/);
|
||||
});
|
||||
|
||||
it('无手机号行跳过(warn 不抛),有手机号行照常', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
fakeRes(200, okBody([user({ id: 1, phone: '' }), user({ id: 2, phone: '13900000002' })], 2)),
|
||||
);
|
||||
const page = await source.listPage();
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.items[0]?.externalUserId).toBe('2');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认失败**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test -- http-user-source.spec`
|
||||
Expected: FAIL(`sourceSystem` 属性不存在 / 类型不匹配 / 去重行为不符)
|
||||
|
||||
- [ ] **Step 3: 重写 http-user-source.ts(完整文件)**
|
||||
|
||||
```ts
|
||||
// services/api/src/user-sync/http-user-source.ts —— uniservice /open/v1/users
|
||||
// 真实源适配器(G4,docs/open-v1-users-api.md;USER_SYNC_SOURCE=http 装配)。
|
||||
//
|
||||
// 游标协议(引擎侧不透明,此处为权威定义):
|
||||
// · cursor 空/未传/纯数值(引擎传入的已提交水位 = updated_at 秒级时间戳)
|
||||
// → 全量第 1 块。上游 API 无增量过滤参数(仅 page/page_size/company_id/
|
||||
// status/keyword/ids),增量不可行——每批全量重拉,幂等由引擎行级
|
||||
// sourceVersion 比较保证(213 用户/200 每页 ≈ 2 页,成本可忽略)。
|
||||
// 纯数值水位绝不当页码用(历史 bug:水位 1758520000 曾被 parseInt 成页号)。
|
||||
// · cursor="p:N"(N≥2)→ 本轮缓存的第 N 块续翻(不发上游请求)。
|
||||
// 拉取策略:首调用拉全上游页 → 全局按 phone 去重(一人多档:同自然人多公司
|
||||
// 多行,保留 updated_at 最新,平手取后见者)→ 按 CHUNK_SIZE 切块缓存。
|
||||
// 引擎互斥锁保证 listPage 串行,实例缓存无并发风险。
|
||||
// 稳定外部 ID:接口 id(int → string)。版本:updated_at epoch 秒(单调可比;
|
||||
// 解析失败得 NaN → 引擎 validateItem 记坏行,不污染水位)。
|
||||
// 状态映射:status=1 在职 → active;0 停用 / 2 离职 → disabled。
|
||||
// 无 phone 行跳过(V1.1 账号=手机号,无手机号无法登录;warn 落日志)。
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type {
|
||||
ExternalUserRecord,
|
||||
UserSourcePage,
|
||||
UserSourcePort,
|
||||
} from './user-source.port';
|
||||
|
||||
/** 接口返回的用户对象(docs/open-v1-users-api.md §3 字段表;未用字段保留声明)。 */
|
||||
interface OpenV1User {
|
||||
id: number;
|
||||
sn: string;
|
||||
name: string;
|
||||
sex: number | null;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string | null;
|
||||
status: number;
|
||||
user_type: number;
|
||||
manager_id: number | null;
|
||||
company_id: number;
|
||||
union_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
company_name: string;
|
||||
company_abbr: string;
|
||||
company_tenant_key: string;
|
||||
feishu_open_id: string | null;
|
||||
feishu_user_id: string | null;
|
||||
feishu_tenant_key: string | null;
|
||||
last_login_at: string | null;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface OpenV1Response {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
items: OpenV1User[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 上游分页上限(文档 §5:200/页)。 */
|
||||
const UPSTREAM_PAGE_SIZE = 200;
|
||||
|
||||
/** 喂引擎的块大小(引擎逐页提交水位——保留分页中断恢复点语义)。 */
|
||||
const CHUNK_SIZE = 200;
|
||||
|
||||
/** 上游页数护栏(total 与分页行为不一致时的死循环保护,1000 页 = 20 万用户)。 */
|
||||
const MAX_UPSTREAM_PAGES = 1_000;
|
||||
|
||||
/** 单请求超时。 */
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** 本源命名空间(external_user_mappings.sourceSystem)。 */
|
||||
export const HTTP_USER_SOURCE_SYSTEM = 'uniservice';
|
||||
|
||||
/** "p:N"(N≥2)→ 第 N 块下标;空/数值水位/非法 → null(全量第 1 块)。 */
|
||||
function parseChunkCursor(raw: string | undefined): number | null {
|
||||
if (raw === undefined || raw === '' || !raw.startsWith('p:')) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseInt(raw.slice(2), 10);
|
||||
return Number.isInteger(n) && n >= 2 ? n - 1 : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HttpUserSource implements UserSourcePort {
|
||||
readonly sourceSystem = HTTP_USER_SOURCE_SYSTEM;
|
||||
|
||||
private readonly logger = new Logger(HttpUserSource.name);
|
||||
|
||||
/** 本轮全量块缓存(null = 未拉取;续翻直接下发,不再回源)。 */
|
||||
private chunks: ExternalUserRecord[][] | null = null;
|
||||
|
||||
async listPage(cursor?: string): Promise<UserSourcePage> {
|
||||
const index = parseChunkCursor(cursor);
|
||||
if (index === null) {
|
||||
this.chunks = await this.fetchAllChunks();
|
||||
}
|
||||
// 续翻时缓存理论恒在(同批互斥串行);异常缺失则空页收尾——安全失败。
|
||||
const chunks = this.chunks ?? [];
|
||||
const i = index ?? 0;
|
||||
return {
|
||||
items: chunks[i] ?? [],
|
||||
nextCursor: i + 1 < chunks.length ? `p:${i + 2}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉全上游页 → phone 全局去重 → 切块。任何上游故障整批抛错(引擎记 failed 批次)。 */
|
||||
private async fetchAllChunks(): Promise<ExternalUserRecord[][]> {
|
||||
const { baseUrl, apiKey } = this.config();
|
||||
const rawUsers: OpenV1User[] = [];
|
||||
let exhausted = false;
|
||||
for (let page = 1; page <= MAX_UPSTREAM_PAGES; page++) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(UPSTREAM_PAGE_SIZE),
|
||||
status: 'all',
|
||||
});
|
||||
const res = await fetch(`${baseUrl}?${params}`, {
|
||||
headers: { 'X-Api-Key': apiKey },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new Error('用户源鉴权失败(Invalid API Key)——检查 OPEN_API_USERS_KEY');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`用户源 HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
const body = (await res.json()) as OpenV1Response;
|
||||
if (!body.success) {
|
||||
throw new Error(`用户源返回失败: ${body.message || 'success=false'}`);
|
||||
}
|
||||
rawUsers.push(...body.data.items);
|
||||
if (body.data.page * body.data.page_size >= body.data.total) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exhausted) {
|
||||
throw new Error(`上游分页超出护栏(>${MAX_UPSTREAM_PAGES} 页),疑似 total 异常`);
|
||||
}
|
||||
|
||||
// 一人多档:同一自然人多公司各一行(不同 id),按 phone 归并——保留
|
||||
// updated_at 最新行(平手取后见者,上游 id 序稳定 → 确定性)。
|
||||
const byPhone = new Map<string, OpenV1User>();
|
||||
for (const raw of rawUsers) {
|
||||
const phone = raw.phone?.trim();
|
||||
if (!phone) {
|
||||
this.logger.warn(`跳过上游用户 id=${raw.id} name=${raw.name}:无手机号(V1.1 账号=手机号)`);
|
||||
continue;
|
||||
}
|
||||
const existing = byPhone.get(phone);
|
||||
if (!existing || raw.updated_at >= existing.updated_at) {
|
||||
byPhone.set(phone, raw);
|
||||
}
|
||||
}
|
||||
|
||||
const records = [...byPhone.values()].map<ExternalUserRecord>((raw) => ({
|
||||
externalUserId: String(raw.id),
|
||||
loginAccount: raw.phone.trim(),
|
||||
displayName: raw.name || raw.sn || raw.phone,
|
||||
department: raw.company_abbr || raw.company_name || '',
|
||||
sourceStatus: raw.status === 1 ? 'active' : 'disabled',
|
||||
sourceVersion: Math.floor(Date.parse(raw.updated_at) / 1000),
|
||||
updatedAt: new Date(raw.updated_at),
|
||||
}));
|
||||
|
||||
const chunks: ExternalUserRecord[][] = [];
|
||||
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
|
||||
chunks.push(records.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private config(): { baseUrl: string; apiKey: string } {
|
||||
const baseUrl = process.env.OPEN_API_USERS_URL;
|
||||
const apiKey = process.env.OPEN_API_USERS_KEY;
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error('HttpUserSource: OPEN_API_USERS_URL / OPEN_API_USERS_KEY 未配置');
|
||||
}
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑单测确认全绿**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test -- http-user-source.spec`
|
||||
Expected: 5 passed
|
||||
|
||||
- [ ] **Step 5: typecheck**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn typecheck` → Expected: 0 error
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/src/user-sync/http-user-source.ts services/api/test/http-user-source.spec.ts
|
||||
git commit -m "feat(api): HttpUserSource adapter for uniservice open/v1/users (G4)
|
||||
|
||||
全量拉取+phone 全局去重(一人多档跨页)、水位游标不当页码回归守护、
|
||||
分块续翻缓存;对齐 UserSourcePort 类型。
|
||||
|
||||
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 模块 env 开关装配 + env 示例
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/src/user-sync/user-sync.module.ts`
|
||||
- Modify: `.env.example`、`.env.production.example-cn`
|
||||
|
||||
- [ ] **Step 1: user-sync.module.ts 整文件替换**
|
||||
|
||||
```ts
|
||||
// services/api/src/user-sync/user-sync.module.ts —— 用户同步域模块(P4a Task 7)。
|
||||
// 仅 cn 装配(AppCnModule import;ov 入口不碰——用户身份是 cn 单域事实)。
|
||||
// 源装配(G4 后双源):USER_SYNC_SOURCE=http → HttpUserSource(uniservice
|
||||
// 真实接口),否则/缺省 → MockUserSource(e2e/本地默认——test 经
|
||||
// app.get(MockUserSource) 注入场景,见 mock-user-source.ts 文件头)。
|
||||
// 其余:SyncLock(cn Redis 互斥)、UserSyncEngine、UserSyncScheduler
|
||||
// (setInterval 定时触发,仅 cn bootstrap 启动)、UserSyncAdminService +
|
||||
// UserSyncController(/admin/user-sync 端点)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpUserSource } from './http-user-source';
|
||||
import { MockUserSource } from './mock-user-source';
|
||||
import { SyncLock } from './sync-lock';
|
||||
import { UserSyncAdminService } from './user-sync-admin.service';
|
||||
import { UserSyncController } from './user-sync.controller';
|
||||
import { UserSyncEngine, UserSyncScheduler } from './user-sync.service';
|
||||
import { USER_SOURCE } from './user-source.port';
|
||||
|
||||
const useHttpSource = process.env.USER_SYNC_SOURCE === 'http';
|
||||
|
||||
@Module({
|
||||
controllers: [UserSyncController],
|
||||
providers: [
|
||||
SyncLock,
|
||||
...(useHttpSource
|
||||
? [HttpUserSource, { provide: USER_SOURCE, useExisting: HttpUserSource }]
|
||||
: [MockUserSource, { provide: USER_SOURCE, useExisting: MockUserSource }]),
|
||||
UserSyncEngine,
|
||||
UserSyncScheduler,
|
||||
UserSyncAdminService,
|
||||
],
|
||||
})
|
||||
export class UserSyncModule {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: env 示例补 USER_SYNC_SOURCE**
|
||||
|
||||
`.env.example`(既有 OPEN_API_USERS_* 块下加一行):
|
||||
|
||||
```bash
|
||||
# 用户同步源:mock(默认,e2e/本地)/ http(uniservice 真实接口)
|
||||
USER_SYNC_SOURCE=mock
|
||||
```
|
||||
|
||||
`.env.production.example-cn` 同位置:
|
||||
|
||||
```bash
|
||||
# 用户同步源:mock(默认)/ http(uniservice 真实接口——生产开)
|
||||
USER_SYNC_SOURCE=http
|
||||
```
|
||||
|
||||
- [ ] **Step 3: e2e 回归(证明缺省 mock 路径不受装配改动影响)**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test:e2e -- user-sync.e2e-spec`
|
||||
Expected: 12 passed
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/src/user-sync/user-sync.module.ts .env.example .env.production.example-cn
|
||||
git commit -m "feat(api): USER_SYNC_SOURCE switch wires mock/http user source (G4)
|
||||
|
||||
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 全量回归门 + 验收记录
|
||||
|
||||
- [ ] **Step 1: API 全量单测**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test` → Expected: 全绿
|
||||
|
||||
- [ ] **Step 2: API 全量 e2e(需 MySQL/Redis 就绪)**
|
||||
|
||||
Run: `pnpm --filter @photo/api-cn test:e2e` → Expected: 全绿(任何既有 spec 不得因 V1.1 挂掉——重点看 admin-users / auth 相关 spec 是否引用哨兵语义)
|
||||
|
||||
若 admin-users.e2e-spec 等有用例依赖「同步建 pending 用户」,按 ⑪ 同语义修正后重跑(预期无需——pending 语义仅 user-sync spec 覆盖)。
|
||||
|
||||
- [ ] **Step 3: 写验收记录 docs/plans/progress/G4-用户接口对接-验收记录.md**
|
||||
|
||||
内容必须包含:范围(半成品收编清单)、关键决策及依据(V1.1 全局生效——用户拍板;全量拉取——API 无增量参数;sourceSystem 随源)、改动文件清单、测试证据(上述命令与结果摘要)、部署注意(生产 env 三变量:`USER_SYNC_SOURCE=http` + `OPEN_API_USERS_URL/KEY`;**本地开发 .env 切勿设 `USER_SYNC_SOURCE=http`**——模块在 import 时读 env,会导致 user-sync e2e 找不到 MockUserSource;既有 `mock:` 命名空间映射成为孤儿属预期——真实用户走 `uniservice:` 新映射;测试服务器 219.157.255.213 待换真实 key 验证一轮属 P5 线)、残留(admin UI sourceVersion 展示从自增版本变为 epoch 秒——纯展示差异)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/plans/progress/G4-用户接口对接-验收记录.md
|
||||
git commit -m "docs(g4): acceptance record for uniservice user source integration
|
||||
|
||||
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 明确不做(YAGNI)
|
||||
|
||||
- 不做飞书登录对接(文档 §5 的 union_id/open_id 议题)——V1.1 凭据方案是手机号+密码,飞书是后续可选线。
|
||||
- 不做 admin 前端改动(/admin/user-sync 端点形状零变化)。
|
||||
- 不做 `ids=` 精确拉取、`company_id` 过滤(同步引擎用不上,全量足够)。
|
||||
- 不做真实接口的线上联调(无 key;测试服务器部署验证归 P5 线,验收记录中登记)。
|
||||
Reference in New Issue
Block a user