Files
photo-live/docs/plans/2026-09-22-G4-用户接口对接-实施计划.md
T
2026-09-22 16:21:26 +08:00

740 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 线,验收记录中登记)。