hiwork-core 0.0.18 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * PMS(`pms.hivery.cn`)个人待办的 HTTP 客户端 —— **只在 Host 半边使用**。
3
+ *
4
+ * 契约见 spec `docs/superpowers/specs/2026-09-17-pms-todo-integration-design.md`(本仓是消费方,
5
+ * 权威定义在 PMS 侧):§D4(三个现有接口)、§D7(**我的任务**:`/api/task/tasks` 取数与完成)、
6
+ * §4.2(`TodoItem` = 服务端 `TodoTaskVO` 的投影)、§7(安全:HTTPS、统一 10s 超时、token 只在 Host)。
7
+ *
8
+ * 与 `src/ai-router-client.ts` 的关系:那是**网关**(ai-router)的 HTTP 面,本文件是**另一个服务**
9
+ * (PMS)的 HTTP 面,两者没有共用的凭据、路由或错误码体系,因此**不合并也不复用**——合并只会让
10
+ * 「网关会话 cookie」与「PMS JWT」混在一层里,那是 §D6 明确禁止的形状。风格(路径常量、错误码
11
+ * 映射、超时、响应体不回显)逐条对齐 `AiRouterClient`:同一个仓库里的两个 HTTP 客户端读起来
12
+ * 应当是一样的。
13
+ *
14
+ * 三条铁律:
15
+ * - **token 只在本层与 Host 内存里**:返回值里的 access token 交给 `src/pms-todo-bridge.ts` 持有,
16
+ * 绝不出现在任何 RPC 响应 / 日志里(本层刻意不提供任何「顺便把 token 带出去」的返回值);
17
+ * - **HTTP 401 与业务 `code` 是两回事**:PMS 未登录时回的是 Spring Security 形状
18
+ * (`{path, code:"AUTH_001", message}`,**不是**业务 `Result` 信封),所以 401 一律按
19
+ * 「凭据失效」收敛,绝不拿它的 `message` 当业务文案(§7:不自动重试、不暴露实现细节);
20
+ * - **错误消息不带响应体**:业务 `code !== 200` 时只把 code 记进 Host 日志,message 一律换成
21
+ * 稳定文案——PMS 的业务 message 里可能带用户数据 / 表名 / SQL 片段。
22
+ *
23
+ * 本模块不 import cordis 运行时,也不 import 任何 DSH 包:它就是 `fetch` + 形状投影。
24
+ */
25
+ import { PKG_VERSION } from "./version.js";
26
+ /**
27
+ * PMS 服务基址 —— **全仓唯一一处**写死这个域名的地方。
28
+ *
29
+ * 为什么固定而不是配置项:PMS 是组织自建且**只有这一个生产部署**的内部系统(不像 ai-router 那样
30
+ * 需要自建/多环境),让每台终端在设置里填一个 URL 只会多一个能填错、又没人能集中改的地方;真要
31
+ * 换部署,改这一个常量、发一版即可。反过来说,**任何别处都不许再写这个域名**(包括文档示例之外
32
+ * 的代码、错误消息、日志),否则「唯一的基址」立刻变成两处能漂移的真相。
33
+ */
34
+ export const PMS_BASE_URL = 'https://pms.hivery.cn';
35
+ /** 认证端点(相对基址)。 */
36
+ const AUTH_LOGIN_PATH = '/api/auth/login';
37
+ const AUTH_REFRESH_PATH = '/api/auth/refresh';
38
+ /** 个人待办端点(相对基址;`setStatus` 的路径按 id 拼,见 {@link PmsClient.setStatus})。 */
39
+ const TODO_TODAY_PATH = '/api/todo/today';
40
+ const TODO_STATISTICS_PATH = '/api/todo/statistics';
41
+ /**
42
+ * 项目任务端点(相对基址,spec §3 D7):列表用 {@link TASK_LIST_PATH} 加 query,
43
+ * 状态变更是 `/api/task/tasks/{id}/status?status=…`(PMS `TaskController` 的 `@RequestMapping`)。
44
+ *
45
+ * ⚠️ 与待办端点**不是**同一个域:任务属项目域(`pms-task`),返回的是**分页对象**(`PageResult`),
46
+ * 待办返回的是数组。两者共用同一个 JWT,但形状、状态取值(数字 vs 字符串)与权限注解都不同。
47
+ */
48
+ const TASK_LIST_PATH = '/api/task/tasks';
49
+ /**
50
+ * 单次请求超时:10 秒(spec §7 的冻结决策)。
51
+ * Host 是唯一会替界面等 PMS 的地方,超时不给足会让右栏面板一直转圈;给太多则用户以为卡死。
52
+ */
53
+ export const PMS_REQUEST_TIMEOUT_MS = 10_000;
54
+ /**
55
+ * access token 的**兜底**有效期:10 小时(= PMS `jwt.expiration=36000000`)。
56
+ * 只在响应缺 `expiresIn` 时用;正常路径一律按服务端给的 `expiresIn` 算过期时刻。
57
+ */
58
+ export const PMS_ACCESS_TOKEN_TTL_MS = 10 * 60 * 60 * 1000;
59
+ /** 业务 `Result` 信封的成功码。 */
60
+ const PMS_RESULT_SUCCESS_CODE = 200;
61
+ /**
62
+ * `Result` 信封里的 `code: 401`。
63
+ *
64
+ * ⚠️ **实测更正(2026-09-18)**:PMS 的业务失败**大多数时候不是 401**。它的
65
+ * `Result.failed(message)` 默认发 `code: 500` 且 `errorType` 为 null,登录失败就是这一种:
66
+ * 账号密码错、账号被禁用、参数为空都是 **HTTP 200 + `{code:500, message:"…"}`**。
67
+ * 只看 code 会把「密码错了」显示成「连不上 PMS」——用户会去查网络,而真正该做的是改密码。
68
+ * 所以判据必须是 **PMS 那句话**(见 {@link isAuthFailureMessage}),401 只是其中一种形态。
69
+ */
70
+ const PMS_RESULT_UNAUTHORIZED_CODE = 401;
71
+ /**
72
+ * PMS 用 `Result.failed(message)` 表示业务失败、`code` 一律 500;这些文案是它写给前端展示的
73
+ * 中文业务文案(不是 SQL / 堆栈),命中即「凭据或账号有问题」。
74
+ *
75
+ * 归到 `UNAUTHORIZED` 的理由:对用户而言「改账号密码 / 找管理员解封」是同一类动作,
76
+ * 而**都不许自动重试**(见 `src/pms-todo-bridge.ts` 的登录注释:避免错密码连续触发 PMS 的失败锁定)。
77
+ * 文案取自 `pms-auth` 的 `AuthService`(登录 / 刷新两条路径的全部失败分支),改 PMS 侧文案要同步这里。
78
+ */
79
+ const PMS_AUTH_FAILURE_KEYWORDS = [
80
+ '用户名或密码错误',
81
+ '用户名不能为空',
82
+ '密码不能为空',
83
+ '用户已被禁用',
84
+ '用户不存在或已被禁用',
85
+ '刷新Token无效或已过期',
86
+ '无法从Token中获取用户信息',
87
+ ];
88
+ /**
89
+ * 一句 PMS 业务文案是不是「凭据 / 账号问题」(而不是服务端故障)。
90
+ * @param message - 信封里的 `message`(形态未知,非字符串一律不算)。
91
+ * @returns 命中任一关键词即为 true。
92
+ */
93
+ function isAuthFailureMessage(message) {
94
+ if (typeof message !== 'string')
95
+ return false;
96
+ return PMS_AUTH_FAILURE_KEYWORDS.some(keyword => message.includes(keyword));
97
+ }
98
+ /**
99
+ * PMS 的「权限不足」业务文案(`AccessDeniedException` 的**固定**文案,两处来源同值:
100
+ * `pms-common/src/main/java/com/pms/common/exception/GlobalExceptionHandler#handleAccessDeniedException`
101
+ * 与 `pms-auth/src/main/java/com/pms/auth/security/JwtAccessDeniedHandler`)。
102
+ *
103
+ * ⚠️ 为什么除了 HTTP 403 还要认这一句(实读 PMS 源码得出的结论):`TaskController` 上完成任务的
104
+ * `@PreAuthorize("hasAuthority('task:list:edit')")` 抛出的 `AccessDeniedException` 是**先**被
105
+ * `@RestControllerAdvice` 接住的,回的是 **HTTP 200 + `{code:500, message:"您没有该操作的权限,请联系管理员分配权限"}`**;
106
+ * 只有过滤器链层面拒绝才会走到 `JwtAccessDeniedHandler` 的 403。两条路径对用户是同一件事
107
+ * (「你在 HiWork 里没有这个权限」),所以都归 `FORBIDDEN`——否则用户看到的是「PMS 暂时不可用」,
108
+ * 然后一直重试,而真正该做的是去 PMS 页面操作(spec §3 D7 要求的提示)。
109
+ *
110
+ * 只认**这一句完整**文案:PMS 的业务 message 里可能出现泛泛的「无权限」措辞(别的语义),
111
+ * 那类失败仍归 `UPSTREAM_UNAVAILABLE`——不拿一个宽泛关键词去猜语义。
112
+ */
113
+ const PMS_PERMISSION_DENIED_MESSAGES = ['您没有该操作的权限,请联系管理员分配权限'];
114
+ /**
115
+ * 一句 PMS 业务文案是不是「权限不足」(而不是凭据问题 / 服务端故障)。
116
+ * @param message - 信封里的 `message`(非字符串一律不算)。
117
+ * @returns 命中固定文案即为 true。
118
+ */
119
+ function isPermissionDeniedMessage(message) {
120
+ if (typeof message !== 'string')
121
+ return false;
122
+ return PMS_PERMISSION_DENIED_MESSAGES.some(text => message.includes(text));
123
+ }
124
+ /** PMS 调用失败。`message` 一律是稳定文案,可直接展示给用户。 */
125
+ export class PmsError extends Error {
126
+ code;
127
+ status;
128
+ constructor(code, message, status) {
129
+ super(message);
130
+ this.code = code;
131
+ this.status = status;
132
+ this.name = 'PmsError';
133
+ }
134
+ }
135
+ /** 未登录 / 凭据失效的文案(与 Bridge 侧 `UNAUTHORIZED` 同口径:引导重新输入,不静默失败)。 */
136
+ export const PMS_UNAUTHORIZED_MESSAGE = '登录已过期,请重新输入 PMS 账号密码';
137
+ /** 通用失败文案:不暴露任何 PMS 实现细节(baseURL / 路径 / 状态码 / 响应体)。 */
138
+ export const PMS_GENERIC_FAILURE_MESSAGE = 'PMS 暂时不可用,请稍后重试';
139
+ /**
140
+ * 权限不足的**固定**文案(spec §3 D7 逐字要求):UI 必须明说「在 HiWork 里没有完成任务的权限」,
141
+ * 而不是静默失败或回滚得莫名其妙——用户该做的是去 PMS 页面操作。
142
+ *
143
+ * 与 `UNAUTHORIZED` 分开的理由:重新登录**不会**解决它(权限是角色给的),把两者混成一个码,
144
+ * 界面就只能给出「请重新输入密码」这种无效引导。
145
+ */
146
+ export const PMS_FORBIDDEN_MESSAGE = '在 HiWork 里没有完成任务的权限,请到 PMS 操作';
147
+ /**
148
+ * 完成任务时用的状态值(PMS `TaskVO.status`:0-待办 / 1-进行中 / 2-已完成 / 3-逾期)。
149
+ * 完成与激活走同一个 `PUT /{id}/status`,差别只有这个 query 值。
150
+ */
151
+ export const PMS_TASK_STATUS_COMPLETED = 2;
152
+ /**
153
+ * 「我的任务」只取**未完成**的三种状态(spec §3 D7 的 `status=0&status=1&status=3`)。
154
+ * 2(已完成)不取:面板是待办视图,把做完的任务一起拉回来只会让列表变长。
155
+ * 顺序即 query 里的出现顺序(由测试钉死)。
156
+ */
157
+ export const PMS_TASK_OPEN_STATUSES = [0, 1, 3];
158
+ /** 「我的任务」的默认页大小(spec §3 D7)。50 条足够覆盖一个人的未完成任务,且一次请求就够。 */
159
+ export const PMS_TASK_PAGE_SIZE = 50;
160
+ /**
161
+ * PMS HTTP 客户端。零 DSH 依赖、零凭据——它只负责「把一次调用发出去、把结果或稳定错误返回」。
162
+ *
163
+ * @param options - 见 {@link PmsClientOptions}。
164
+ */
165
+ export class PmsClient {
166
+ baseURL;
167
+ timeoutMs;
168
+ now;
169
+ log;
170
+ constructor(options = {}) {
171
+ this.baseURL = (options.baseURL ?? PMS_BASE_URL).replace(/\/+$/, '');
172
+ this.timeoutMs = options.timeoutMs ?? PMS_REQUEST_TIMEOUT_MS;
173
+ this.now = options.now ?? (() => Date.now());
174
+ this.log = options.log;
175
+ }
176
+ /**
177
+ * `POST /api/auth/login`:用账号密码换 JWT(access + refresh)。
178
+ *
179
+ * 失败语义(由 {@link PmsClient.call} 统一给出):
180
+ * - 账号密码不对 → `UNAUTHORIZED`(HTTP 401 **或** 信封 `code:401`,两者同义);
181
+ * - 其余(网络 / 超时 / 5xx / 非业务信封)→ `UPSTREAM_UNAVAILABLE`。
182
+ * **本方法不重试**,调用方也不许替它重试(§7:避免用错密码连续触发 PMS 的失败锁定)。
183
+ */
184
+ async login(username, password) {
185
+ const envelope = await this.call(AUTH_LOGIN_PATH, { method: 'POST', body: { username, password } });
186
+ return this.tokenSet(envelope.data);
187
+ }
188
+ /**
189
+ * `POST /api/auth/refresh`:用 refresh token 续一次 access token。
190
+ *
191
+ * refresh token 失效(7 天到期 / 已被 PMS 撤销)→ `UNAUTHORIZED`,此时**只能**重新输入账号密码;
192
+ * 网络问题 → `UPSTREAM_UNAVAILABLE`(那是「稍后重试」,不是「要重新登录」——两者对用户是两件事)。
193
+ */
194
+ async refresh(refreshToken) {
195
+ const envelope = await this.call(AUTH_REFRESH_PATH, { method: 'POST', body: { refreshToken } });
196
+ return this.tokenSet(envelope.data);
197
+ }
198
+ /**
199
+ * `GET /api/todo/today`:今日待办(⚠️ 口径比名字宽:**今天 + 明天 + 无截止日期**都算,spec §D4)。
200
+ *
201
+ * `data` 必须是数组;不是数组时按**上游不可用**处理(绝不读成「今天没有待办」——那是把中间层的
202
+ * 维护页当成空清单,用户会以为自己清空了所有待办)。缺 `id` 的条目跳过:没有 id 就没法勾选,
203
+ * 渲染出来只会是一个点了没反应的复选框。
204
+ */
205
+ async today(accessToken) {
206
+ const envelope = await this.call(TODO_TODAY_PATH, { method: 'GET', accessToken });
207
+ if (!Array.isArray(envelope.data)) {
208
+ this.log?.('hiwork-core: PMS 今日待办响应不是数组(可能的中间层),按上游不可用处理');
209
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
210
+ }
211
+ const items = [];
212
+ for (const raw of envelope.data) {
213
+ const item = toTodoItem(raw);
214
+ if (item === undefined) {
215
+ this.log?.('hiwork-core: PMS 今日待办里有一条缺 id 的条目,已跳过');
216
+ continue;
217
+ }
218
+ items.push(item);
219
+ }
220
+ return items;
221
+ }
222
+ /** `GET /api/todo/statistics`:total / pending / completed / overdue 四个计数。 */
223
+ async statistics(accessToken) {
224
+ const envelope = await this.call(TODO_STATISTICS_PATH, { method: 'GET', accessToken });
225
+ if (!isRecord(envelope.data)) {
226
+ this.log?.('hiwork-core: PMS 待办统计响应不是对象(可能的中间层),按上游不可用处理');
227
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
228
+ }
229
+ return {
230
+ total: countOf(envelope.data['totalCount']),
231
+ pending: countOf(envelope.data['pendingCount']),
232
+ completed: countOf(envelope.data['completedCount']),
233
+ overdue: countOf(envelope.data['overdueCount']),
234
+ };
235
+ }
236
+ /**
237
+ * `PUT /api/todo/{id}/status?status=PENDING|COMPLETED`:勾完成 / 取消完成。
238
+ *
239
+ * `status` 走 **query**(PMS 侧就是这么定的),所以必须 `encodeURIComponent`——虽然本层只放行
240
+ * 两个字面量、编码后与原值相同,但「拼进 URL 的值一律编码」是纪律:少一次编码在参数集合变化时
241
+ * 就是一个注入面。
242
+ *
243
+ * ⚠️ 副作用(spec §D5):勾完成时若来源是项目任务且该任务下再无 PENDING 待办,**PMS 会把项目
244
+ * 任务一并标记完成**;反向不成立。本层只负责发请求,提示文案属 UI 的职责。
245
+ *
246
+ * 返回值以服务端回声为准(`data.id` / `data.status`),`data` 为空(PMS 这个接口可能回 void)
247
+ * 时回退成请求里的 id / status——调用方要的是「我勾的是哪条、现在是什么状态」,两者都知道。
248
+ */
249
+ async setStatus(accessToken, id, status) {
250
+ const path = `/api/todo/${encodeURIComponent(String(id))}/status?status=${encodeURIComponent(status)}`;
251
+ const envelope = await this.call(path, { method: 'PUT', accessToken });
252
+ const data = envelope.data;
253
+ const completedTime = isRecord(data) ? textOf(data['completedTime']) : undefined;
254
+ return {
255
+ id: isRecord(data) && isInteger(data['id']) ? data['id'] : id,
256
+ status: isRecord(data) && isTodoStatusValue(data['status']) ? data['status'] : status,
257
+ ...(completedTime === undefined ? {} : { completedTime }),
258
+ };
259
+ }
260
+ /**
261
+ * `GET /api/task/tasks?assigneeId={userId}&status=0&status=1&status=3&current=1&size=50`
262
+ * (spec §3 D7):**我的未完成项目任务**。
263
+ *
264
+ * 为什么必须直接查任务而不是只读待办:PMS 只在**任务创建那一刻**生成待办
265
+ * (`TaskService.createFromTask`),转派、走 SQL 批量建的历史任务、以及机制上线前的任务都
266
+ * 没有待办——只读 `todo_task` 会让这些任务在面板上凭空消失(§3 D7 的缺口表)。
267
+ *
268
+ * `status` 在 PMS 侧是 `List<Integer>`,所以是**多个同名参数**而不是逗号分隔(`status=0&status=1&status=3`);
269
+ * 顺序按 {@link PMS_TASK_OPEN_STATUSES},`assigneeId` 是**当前登录人的 PMS userId**
270
+ * (由调用方从凭据记录里取,见 `src/pms-todo-bridge.ts`)。
271
+ *
272
+ * ⚠️ 响应的 `data` 是**分页对象**(`{records, total, current, size}`)**不是数组**:
273
+ * 不是对象或 `records` 不是数组时按**上游不可用**处理(绝不读成「我没有任务」——那会让用户
274
+ * 以为活都干完了,而这正是本次最要避免的误读)。
275
+ *
276
+ * @param accessToken - Host 内存里的 PMS JWT。
277
+ * @param userId - 我的 PMS userId(凭据记录里的 `userId`)。
278
+ * @param opts - 见 {@link PmsMyTasksOptions}。
279
+ */
280
+ async myTasks(accessToken, userId, opts = {}) {
281
+ const size = isPositiveInteger(opts.size) ? opts.size : PMS_TASK_PAGE_SIZE;
282
+ // URLSearchParams 负责百分号编码(「拼进 URL 的值一律编码」的纪律);同名参数 append 多次。
283
+ const query = new URLSearchParams();
284
+ query.append('assigneeId', String(userId));
285
+ for (const status of PMS_TASK_OPEN_STATUSES)
286
+ query.append('status', String(status));
287
+ query.append('current', '1');
288
+ query.append('size', String(size));
289
+ const path = `${TASK_LIST_PATH}?${query.toString()}`;
290
+ const envelope = await this.call(path, { method: 'GET', accessToken });
291
+ const data = envelope.data;
292
+ if (!isRecord(data) || !Array.isArray(data['records'])) {
293
+ this.log?.(`hiwork-core: PMS 我的任务响应的 data 不是分页对象(可能的中间层),按上游不可用处理`);
294
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
295
+ }
296
+ const items = [];
297
+ for (const raw of data['records']) {
298
+ const item = toTaskItem(raw);
299
+ if (item === undefined) {
300
+ this.log?.('hiwork-core: PMS 我的任务里有一条缺 id 的任务,已跳过');
301
+ continue;
302
+ }
303
+ items.push(item);
304
+ }
305
+ return {
306
+ items,
307
+ // 分页元数据缺失时给一个**自洽**的回退:总数 = 这次拿到的条数,页码 1,页大小 = 请求值。
308
+ total: countOr(data['total'], items.length),
309
+ current: countOr(data['current'], 1),
310
+ size: countOr(data['size'], size),
311
+ };
312
+ }
313
+ /**
314
+ * `PUT /api/task/tasks/{taskId}/status?status=2`:**完成一条项目任务**(spec §3 D7)。
315
+ *
316
+ * 与 {@link PmsClient.setStatus} 同款:`status` 走 **query** 且被编码;要求业务 `code === 200`,
317
+ * `data` 容许为 `null`(PMS 这个接口的返回类型是 `Result<Void>`,实测就是 null),此时回退成
318
+ * 请求里的 id / 状态数字——调用方要的是「我完成的是哪条、现在是什么状态」,两者都知道。
319
+ *
320
+ * ⚠️ 权限:这个端点带 `@PreAuthorize("hasAuthority('task:list:edit')")`,没有该权限的角色会失败
321
+ * (`FORBIDDEN`,见 {@link PMS_FORBIDDEN_MESSAGE})。这与待办那条不同——**不要**把它当成「凭据
322
+ * 过期」让用户重新登录。
323
+ */
324
+ async completeTask(accessToken, taskId) {
325
+ const path = `${TASK_LIST_PATH}/${encodeURIComponent(String(taskId))}/status`
326
+ + `?status=${encodeURIComponent(String(PMS_TASK_STATUS_COMPLETED))}`;
327
+ const envelope = await this.call(path, { method: 'PUT', accessToken });
328
+ const data = envelope.data;
329
+ return {
330
+ id: isRecord(data) && isInteger(data['id']) ? data['id'] : taskId,
331
+ status: isRecord(data) && isInteger(data['status']) ? data['status'] : PMS_TASK_STATUS_COMPLETED,
332
+ };
333
+ }
334
+ /**
335
+ * 发一次调用并解开 `Result` 信封。**所有错误都在这里收敛**,返回值只有 `data`。
336
+ *
337
+ * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
338
+ * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
339
+ * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
340
+ * 误判成「上游故障」而让用户不知道要重新输入密码。
341
+ */
342
+ async call(path, opts) {
343
+ let response;
344
+ try {
345
+ const headers = {
346
+ 'user-agent': `hiwork-core/${PKG_VERSION}`,
347
+ accept: 'application/json',
348
+ };
349
+ if (opts.body !== undefined)
350
+ headers['content-type'] = 'application/json';
351
+ // Bearer 头只在有 token 时发(登录/刷新那两条本来就不需要)。
352
+ if (opts.accessToken !== undefined)
353
+ headers['authorization'] = `Bearer ${opts.accessToken}`;
354
+ const init = {
355
+ method: opts.method,
356
+ headers,
357
+ signal: AbortSignal.timeout(this.timeoutMs),
358
+ };
359
+ if (opts.body !== undefined)
360
+ init.body = JSON.stringify(opts.body);
361
+ response = await fetch(`${this.baseURL}${path}`, init);
362
+ }
363
+ catch {
364
+ // DNS / 连接失败 / TLS / 超时(AbortSignal.timeout)——一律「上游不可用」,
365
+ // 且**不**把底层异常信息带出去(它可能含内网地址)。
366
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
367
+ }
368
+ if (response.status === 401) {
369
+ throw new PmsError('UNAUTHORIZED', PMS_UNAUTHORIZED_MESSAGE, 401);
370
+ }
371
+ if (response.status === 403) {
372
+ // 权限不足(`task:list:edit` 等 `@PreAuthorize` 在**过滤器链**层面拒绝时的形态):
373
+ // 重新登录解决不了它,所以既不是 `UNAUTHORIZED` 也不是「上游故障」(spec §3 D7)。
374
+ // 与 401 同款:**不读响应体**(Spring Security 那个 403 体里带 path / AUTH_002 等实现细节)。
375
+ this.log?.(`hiwork-core: PMS ${path} 拒绝授权(HTTP 403)`);
376
+ throw new PmsError('FORBIDDEN', PMS_FORBIDDEN_MESSAGE, 403);
377
+ }
378
+ // 用 readJson 而不是 response.json():JSON.parse 的报错会把响应片段带进异常信息。
379
+ const payload = await readJson(response);
380
+ if (!isRecord(payload)) {
381
+ // 200/5xx + 非 JSON(反代维护页 / HTML 错误页)→ 上游不可用;**不**回显响应内容。
382
+ this.log?.(`hiwork-core: PMS ${path} 返回了非 JSON 响应(HTTP ${response.status})`);
383
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
384
+ }
385
+ const code = payload['code'];
386
+ if (typeof code !== 'number' || !Number.isFinite(code)) {
387
+ this.log?.(`hiwork-core: PMS ${path} 响应缺业务 code(可能的中间层)`);
388
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
389
+ }
390
+ if (code === PMS_RESULT_UNAUTHORIZED_CODE || isAuthFailureMessage(payload['message'])) {
391
+ // 两种形态同义:信封 401,以及 `Result.failed(message)` 的 500 +「用户名或密码错误」这类文案。
392
+ // **只记事件不记原文**(文案虽是人话,但纪律上响应体不进日志)。
393
+ this.log?.(`hiwork-core: PMS ${path} 拒绝凭据(code=${code})`);
394
+ throw new PmsError('UNAUTHORIZED', PMS_UNAUTHORIZED_MESSAGE, response.status);
395
+ }
396
+ if (code !== PMS_RESULT_SUCCESS_CODE) {
397
+ if (isPermissionDeniedMessage(payload['message'])) {
398
+ // 权限不足的**另一种形态**(更常见,见 `PMS_PERMISSION_DENIED_MESSAGES` 的注释):
399
+ // HTTP 200 + code:500 +「您没有该操作的权限…」。**只记事件不记原文**。
400
+ this.log?.(`hiwork-core: PMS ${path} 拒绝授权(code=${code})`);
401
+ throw new PmsError('FORBIDDEN', PMS_FORBIDDEN_MESSAGE, response.status);
402
+ }
403
+ // 业务失败:**只记 code**,message 换成稳定文案(响应体可能带用户数据 / 实现细节)。
404
+ this.log?.(`hiwork-core: PMS ${path} 返回业务失败(code=${code})`);
405
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
406
+ }
407
+ return { code, data: payload['data'] };
408
+ }
409
+ /** 登录 / 刷新的 `data` → {@link PmsTokenSet}。200 却不给 token 视为契约破坏(上游不可用)。 */
410
+ tokenSet(data) {
411
+ if (!isRecord(data)) {
412
+ this.log?.('hiwork-core: PMS 认证响应缺 data(契约破坏),按上游不可用处理');
413
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
414
+ }
415
+ const accessToken = textOf(data['token']);
416
+ if (accessToken === undefined) {
417
+ this.log?.('hiwork-core: PMS 认证响应缺 token(契约破坏),按上游不可用处理');
418
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
419
+ }
420
+ const refreshToken = textOf(data['refreshToken']);
421
+ const expiresIn = data['expiresIn'];
422
+ // `expiresIn` 是**秒**(PMS 侧 `jwt.expiration/1000` 量级),非正数 / 缺失按 10 小时兜底。
423
+ const ttlMs = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0
424
+ ? expiresIn * 1000
425
+ : PMS_ACCESS_TOKEN_TTL_MS;
426
+ // `userId` 只在**是安全整数**时收下(登录响应里就有,spec §3 D7:我的任务要用它做 assigneeId);
427
+ // 形态不对就当没给——「我的任务」会据此引导用户重新登录一次,比拿一个脏 id 去查询好。
428
+ const userId = isInteger(data['userId']) ? data['userId'] : undefined;
429
+ return {
430
+ accessToken,
431
+ ...(refreshToken === undefined ? {} : { refreshToken }),
432
+ ...(userId === undefined ? {} : { userId }),
433
+ expiresAt: this.now() + ttlMs,
434
+ };
435
+ }
436
+ }
437
+ /** 宽松的记录判定(数组与 null 都不算)。 */
438
+ function isRecord(value) {
439
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
440
+ }
441
+ /** 非空字符串或 undefined(一个字段坏了不该让整条待办失败)。 */
442
+ function textOf(value) {
443
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
444
+ }
445
+ /** 安全整数判定(id 用)。 */
446
+ function isInteger(value) {
447
+ return typeof value === 'number' && Number.isSafeInteger(value);
448
+ }
449
+ /** 正整数判定(`size` 之类的分页参数用;`undefined` 不算)。 */
450
+ function isPositiveInteger(value) {
451
+ return isInteger(value) && value > 0;
452
+ }
453
+ /** 勾选值的白名单(只有这两个是契约里的字面量)。 */
454
+ function isTodoStatusValue(value) {
455
+ return value === 'PENDING' || value === 'COMPLETED';
456
+ }
457
+ /** 计数兜底:非有限数一律 0(统计缺一个字段不该让整个面板失败)。 */
458
+ function countOf(value) {
459
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
460
+ }
461
+ /** 计数兜底(带自己的缺省值):用于分页元数据——缺了要给一个**自洽**的值而不是 0。 */
462
+ function countOr(value, fallback) {
463
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
464
+ }
465
+ /** 数字字段兜底:非有限数一律 0(`TaskVO` 的数字字段 nullable,缺了不该让整条任务失败)。 */
466
+ function numberOf(value) {
467
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
468
+ }
469
+ /**
470
+ * 把服务端的一条 `TodoTaskVO` 投影成 {@link PmsTodoItem}。
471
+ *
472
+ * **只在缺(或非法)`id` 时返回 undefined**——没有 id 就没法勾选,渲染出来是一个点了没反应的
473
+ * 复选框。其余字段一律兜底:`title` / `status` 缺省空串(契约里的必填项),可选字段**按服务端
474
+ * 有没有来**(给了才写这个键,值不是字符串/布尔就不写),`priority` / `status` / `category`
475
+ * 的未知取值**原样透传**(§4.2 的演进纪律:不认识的枚举不是错误)。
476
+ */
477
+ function toTodoItem(raw) {
478
+ if (!isRecord(raw))
479
+ return undefined;
480
+ const id = raw['id'];
481
+ if (!isInteger(id))
482
+ return undefined;
483
+ const item = {
484
+ id,
485
+ title: textOf(raw['title']) ?? '',
486
+ status: textOf(raw['status']) ?? '',
487
+ };
488
+ const description = textOf(raw['description']);
489
+ if (description !== undefined)
490
+ item.description = description;
491
+ const category = textOf(raw['category']);
492
+ if (category !== undefined)
493
+ item.category = category;
494
+ const priority = textOf(raw['priority']);
495
+ if (priority !== undefined)
496
+ item.priority = priority;
497
+ const startDate = textOf(raw['startDate']);
498
+ if (startDate !== undefined)
499
+ item.startDate = startDate;
500
+ const dueDate = textOf(raw['dueDate']);
501
+ if (dueDate !== undefined)
502
+ item.dueDate = dueDate;
503
+ const completedTime = textOf(raw['completedTime']);
504
+ if (completedTime !== undefined)
505
+ item.completedTime = completedTime;
506
+ // 两个服务端判定:只认布尔(`true` / `false` 都照发,非布尔不写)。
507
+ if (typeof raw['isOverdue'] === 'boolean')
508
+ item.isOverdue = raw['isOverdue'];
509
+ if (typeof raw['isDueSoon'] === 'boolean')
510
+ item.isDueSoon = raw['isDueSoon'];
511
+ return item;
512
+ }
513
+ /**
514
+ * 把服务端的一条 `TaskVO` 投影成 {@link PmsTaskItem}(spec §3 D7)。
515
+ *
516
+ * **只在缺(或非法)`id` 时返回 undefined**——没有 id 就没法完成它,渲染出来是一个点了没反应的
517
+ * 复选框(与 {@link toTodoItem} 同款判据)。其余字段一律兜底:设计里没标 `?` 的按类型给缺省
518
+ * (`''` / `0`),标了 `?` 的那五个**只按服务端给没给来**。**未知字段忽略**(§4.2 的演进纪律:
519
+ * 老 core 不认识的新字段不进契约面),而 `status` / `priority` 这些**数字**本来就照发
520
+ * ——PMS 加一个状态档位(例如 4)不该让老客户端崩,也不该被 Host 丢掉。
521
+ */
522
+ function toTaskItem(raw) {
523
+ if (!isRecord(raw))
524
+ return undefined;
525
+ const id = raw['id'];
526
+ if (!isInteger(id))
527
+ return undefined;
528
+ const item = {
529
+ id,
530
+ taskName: textOf(raw['taskName']) ?? '',
531
+ description: textOf(raw['description']) ?? '',
532
+ projectId: numberOf(raw['projectId']),
533
+ projectName: textOf(raw['projectName']) ?? '',
534
+ assigneeId: numberOf(raw['assigneeId']),
535
+ assigneeName: textOf(raw['assigneeName']) ?? '',
536
+ deadline: textOf(raw['deadline']) ?? '',
537
+ status: numberOf(raw['status']),
538
+ statusName: textOf(raw['statusName']) ?? '',
539
+ priority: numberOf(raw['priority']),
540
+ priorityName: textOf(raw['priorityName']) ?? '',
541
+ taskType: textOf(raw['taskType']) ?? '',
542
+ taskTypeName: textOf(raw['taskTypeName']) ?? '',
543
+ auditStatus: numberOf(raw['auditStatus']),
544
+ auditStatusName: textOf(raw['auditStatusName']) ?? '',
545
+ };
546
+ // 设计里明确标 `?` 的五个:服务端给了才写这个键(没给就是「没有」,不是空串)。
547
+ const projectStageName = textOf(raw['projectStageName']);
548
+ if (projectStageName !== undefined)
549
+ item.projectStageName = projectStageName;
550
+ const requirementName = textOf(raw['requirementName']);
551
+ if (requirementName !== undefined)
552
+ item.requirementName = requirementName;
553
+ const moduleName = textOf(raw['moduleName']);
554
+ if (moduleName !== undefined)
555
+ item.moduleName = moduleName;
556
+ const estimatedHours = raw['estimatedHours'];
557
+ if (typeof estimatedHours === 'number' && Number.isFinite(estimatedHours))
558
+ item.estimatedHours = estimatedHours;
559
+ const actualHours = raw['actualHours'];
560
+ if (typeof actualHours === 'number' && Number.isFinite(actualHours))
561
+ item.actualHours = actualHours;
562
+ return item;
563
+ }
564
+ /** 读取 JSON 响应体:解析失败返回 undefined(不抛错、不记录原文,避免敏感字段进日志)。 */
565
+ async function readJson(response) {
566
+ try {
567
+ return await response.json();
568
+ }
569
+ catch {
570
+ return undefined;
571
+ }
572
+ }