From 9c6e9c66ad85b2ed62b77b5d8ee281ceb1b57f06 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 15:15:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E4=BF=AE=E5=A4=8D:=20?= =?UTF-8?q?=E9=B8=BF=E8=92=99=E5=AE=A2=E6=88=B7=E7=AB=AF"=E8=BF=9E?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A=E6=9C=8D=E5=8A=A1=E5=99=A8"=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9C=B0=E5=9D=80=E8=A1=A5=20/api/v1=20+=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=88=86=E7=B1=BB=E6=88=90=E4=BA=BA=E8=AF=9D?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E7=BD=91=E7=BB=9C=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E5=9B=BA=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 症状 鸿蒙客户端(client/harmony)连不上服务端,界面只显示"无法连接",用户无法自助; 而服务端 HTTPS 完全正常:https://mail.jianfgit.xyz/health → 200、 /api/v1/auth/me → 401、/api/v1/events/stream → 401(Let's Encrypt *.jianfgit.xyz, TLS 校验通过;代理与直连 http://127.0.0.1:8180 行为一致)。 根因(两条,逐条核实过,其中一条**推翻了原判断**) ① apiBase 是"API 前缀本身"(ApiClient 里拼的是 '/auth/login' 这类相对路径), 而 Ui 只做 trim+去尾斜杠:用户若只填 `https://mail.jianfgit.xyz/`,请求就打到 `https://mail.jianfgit.xyz/auth/login` ⇒ 404,客户端再把它压成"无法连接"。 **这是本次故障最可能的直接原因。** ② 默认值 `http://192.168.2.60:8180/api/v1` 是明文 + 写死内网 IP:手机不在同网段 就永远不通(mail.jianfgit.xyz 解析到的就是这台内网机)。 ★ 但"鸿蒙默认禁止明文 HTTP"这条**不成立**,已按本机离线官方文档核实: `devecocli docs read .../使用HTTP访问网络/http-request` 的《明文HTTP访问权限配置说明》 写明 cleartextTrafficPermitted "默认为 true",Network Kit 默认允许明文; 另有 FAQ《Stage模型如何配置支持http明文传输》:"无需配置,支持HTTP明文传输数据"。 ⇒ network_config.json 按"显式固化意图"处理(照文档形状写对,但**不冒充**它是修复)。 本机 SDK @ohos.net.http.d.ts 里确实有 2300997 Cleartext traffic not permitted(since 18), 所以那条错误码在客户端被建成一条可读提示,而不是被忽略。 改法 · 新增 model/ApiBase.ts(纯逻辑,无 @ohos,判据能用 node 直接跑): normalizeApiBase(去尾斜杠**但不咬协议 //**、末尾没有 /api/v1 就补、已有的一字不动)、 validateApiBase(自带修法的中文提示 + "公网明文才告警、内网明文不误报")、 describeFailure(404 自己写文案并点名 /api/v1;其它状态码让服务端文案说话; 网络层按 2300006/2300007/2300028/2300997/2300998/2300058-60-77 分类成人话)。 · Config.ets:DEFAULT_API_BASE → https://mail.jianfgit.xyz/api/v1。 · ApiClient.ets:setBase/init **都**过 normalizeApiBase(唯一闸口 ⇒ 老装机里已经存下的 坏地址在读回时就治好,光改默认值救不了它);ApiError 带 nativeCode;错误路径改走 describeFailure;404 的提示指向"地址少了 /api/v1"。 · LoginPage.ets:地址先校验后持久化(不合法**不落库**、给可执行提示),明文警告常驻渲染; SettingsPage.ets:添加账号同样校验(多账号库直接喂 SseService,坏地址会让该账号的实时 通道永久连不上);AccountManager/SseService 落库与建连时各自再归一化一次。 · 新增 resources/base/profile/network_config.json:按官方文档形状把内网明文 (192.168.2.60 / 10.0.2.2 / localhost)显式列进 domain-config 白名单。 文档给的就是这个**固定路径与文件名**,不需要在 module.json5 里写引用 (仓库里既有的 HomeAgent 工程同样是这么放的)。 · 新增判据 test/harmony-apibase.test.mjs(13 条)并接进 run-all.mjs 的 SUITE: 值判据**直接跑** model/ApiBase.ts;.ets 那几条是**静态**接线判据(本机无设备)。 验证(都真跑过) · cd client/harmony && devecocli build clean && devecocli build → BUILD SUCCESSFUL in 7 s 186 ms;entry/build/default/outputs/default/entry-default-signed.hap 存在(1214592 B,15:14)。 · 解包 HAP:resources/base/profile/network_config.json 在包里、JSON 可解析、 cleartextTrafficPermitted=true 且白名单含 192.168.2.60/10.0.2.2/localhost; bundleName 仍是 com.jianf.agentmail,module.json 没有多余的 metadata/securityConfiguration。 · devecocli check lint → 0 error;我改过的文件**零发现**(总工性从 8 降到 7, 顺带修掉 LoginPage 一条既有的 await-thenable)。 · node --experimental-strip-types --no-warnings --test test/harmony-apibase.test.mjs → ℹ tests 13 / pass 13 / fail 0。 · 变异验证 11/11 全红且**红在对应那条**(在 /tmp 的独立 worktree 里做的,不碰共享树): 归一化不补后缀、去斜杠咬掉协议、404 用通用文案、401 拿通用文案顶掉服务端原因、 网络码不再分类、默认地址退回明文内网、setBase 直接赋值、登录页去掉守卫、 内网白名单关掉明文、出现第二处自己拼 /api/v1、守卫变成空壳。 (M8 第一次是**假绿**——只判了方法声明、没判调用点;改成切出 doLogin 方法体后再判才红。) 未验到(别把"编译过了"读成"连通了") · **没有**在真机/模拟器上点过一次登录:本机当前无设备在线,所以"真的连上了服务端" 这件事本轮**未被验证**;已验证的只是"地址会被补成带 /api/v1 的形态""构建产物正确"。 · network_config.json 的**实际效果**未验:按官方文档明文默认就允许,这份文件是显式固化, 没有做"关掉它再对比"的实验(也无法在无设备时做)。 · DNS/超时/证书这几条分类的文案是按 SDK 错误码写的,**没有构造真人故障去实测** (即没有真的把 DNS 打坏、把证书换成自签来看提示)。 · 登录页那条"未 /api/v1 会 404"的因果链是从服务端路由 + 客户端拼接方式推出的, 没有用 curl 对 `https://mail.jianfgit.xyz/auth/login` 实打一次取证。 · test/run-all.mjs 在本机(node v24.14.1)**本来就是红的**:它只认 `# pass N`, 而 node 24 打的是 `ℹ pass N` ⇒ 25 个文件里 21 个被记成"没自报条数"。 这是既有环境漂移(已在 HEAD 的 worktree 里复现同样的红),**本次没有动它**, 所以新判据虽然已登记进 SUITE,要等 runner 的 marker 解析修好才会被套件真正计数。 --- client/electron/test/harmony-apibase.test.mjs | 304 ++++++++++++++++ client/electron/test/run-all.mjs | 4 + .../entry/src/main/ets/api/AccountManager.ets | 8 +- .../entry/src/main/ets/api/ApiClient.ets | 50 ++- .../entry/src/main/ets/api/SseService.ets | 3 +- .../entry/src/main/ets/common/Config.ets | 26 +- .../entry/src/main/ets/model/ApiBase.ts | 330 ++++++++++++++++++ .../entry/src/main/ets/pages/LoginPage.ets | 58 ++- .../entry/src/main/ets/pages/SettingsPage.ets | 30 +- .../base/profile/network_config.json | 23 ++ 10 files changed, 794 insertions(+), 42 deletions(-) create mode 100644 client/electron/test/harmony-apibase.test.mjs create mode 100644 client/harmony/entry/src/main/ets/model/ApiBase.ts create mode 100644 client/harmony/entry/src/main/resources/base/profile/network_config.json diff --git a/client/electron/test/harmony-apibase.test.mjs b/client/electron/test/harmony-apibase.test.mjs new file mode 100644 index 0000000..9810cde --- /dev/null +++ b/client/electron/test/harmony-apibase.test.mjs @@ -0,0 +1,304 @@ +/* + * 鸿蒙客户端「连不上服务器」那一族(apiBase 归一化/校验 + 失败说人话)的判据。 + * + * ── 背景(2026-09-15)── + * + * 用户报"鸿蒙客户端连不上服务器",而服务端 `https://mail.jianfgit.xyz/health` 是 200 + * (证书 Let's Encrypt、TLS 校验通过)。两个坑叠在一起: + * ① 默认地址是 `http://192.168.2.60:8180/api/v1` —— 明文 + 写死内网 IP, + * 手机不在这个网段就永远连不上,而报错只说"无法连接"; + * ② `apiBase` 是**完整 API 前缀**(`ApiClient` 拼的是 `'/auth/login'` 这类相对路径), + * 用户只填 `https://mail.jianfgit.xyz/` 时请求变成 `https://…/auth/login` + * ⇒ 服务端 **404**,界面依旧只显示"无法连接" —— **本次最可能的直接原因**。 + * + * ── 判据怎么分层(按 `test/CRITERIA.md` §6.7.0 的分流)── + * + * · **值**:`model/ApiBase.ts` 是纯逻辑(无 `@ohos` 依赖,类型可擦除), + * 用 node 的 `--experimental-strip-types` **直接执行它** —— 补后缀、协议保护、 + * 校验、失败分类全部是**行为判据**(跑的是客户端真正引用的那一份,不是复制品)。 + * · **来源**:`.ets` 在本机没有运行时(要编译要设备 ⇒ 见 §6.8 的欠账机制), + * 所以"页面真的调了归一化""坏地址真的没被存下来"只能是**静态**判据。 + * ⚠️ **它证明形状,不证明值** —— 别把它读成"用户点一下就好了"。 + * + * 与「hdc / 真机」的关系:本机 `hdc list targets` 可用时,这一族应该升级成 + * 真机点一次登录(那才是 §7 说的"用户真正会点的那一层")。当前没有设备, + * 所以这里如实标注为静态欠账,不冒充端到端验证。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { code } from './lib/read.mjs'; + +/* + * ★ 仓库根从**本文件的位置**推,不许硬编码绝对路径 —— worktree 复核时硬编码会 + * 静默读另一棵树并报绿(本仓 2026-09-15 真发生过)。 + */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); +const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); +const PROFILE_DIR = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile'); +const NET_CFG = join(PROFILE_DIR, 'network_config.json'); + +/** 被测对象:客户端真正引用的那份逻辑(不是复制品) */ +const A = await import(pathToFileURL(join(ETS, 'model/ApiBase.ts')).href); + +const read = (rel) => code(join(ETS, rel)); + +/** 递归列出 ets 树下的 .ets/.ts(扫目录的判据必须能自证"扫到了东西",见 §6) */ +function sourceFiles(dir = ETS) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) { out.push(...sourceFiles(p)); continue; } + if (e.name.endsWith('.ets') || e.name.endsWith('.ts')) out.push(p); + } + return out; +} + +const rel = (p) => p.slice(ETS.length + 1); + +/* ───────────────────────── 值判据:跑真逻辑 ───────────────────────── */ + +test('★ 归一化:末尾没有 /api/v1 就补上(用户踩的就是这个坑),已有的一字不动(不重复补)', () => { + assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz'), 'https://mail.jianfgit.xyz/api/v1', + '少了 /api/v1 就会请求到 /auth/login ⇒ 404。**正确修法**:归一化时补上。' + + '**最常见的错误修法**:在页面里各写一遍 trim/拼接(两处口径必然漂移)。'); + assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz/'), 'https://mail.jianfgit.xyz/api/v1', + '尾斜杠要先去掉再补,否则拼出 `//api/v1`'); + assert.equal(A.normalizeApiBase('http://192.168.2.60:8180'), 'http://192.168.2.60:8180/api/v1', + '带端口的形态同样要补'); + assert.equal(A.normalizeApiBase(' https://mail.jianfgit.xyz '), 'https://mail.jianfgit.xyz/api/v1', + '首尾空白(粘贴常见)要去掉'); + + // 反向:已经带 /api/v1 的不许再补一层 + for (const v of ['https://mail.jianfgit.xyz/api/v1', 'https://mail.jianfgit.xyz/api/v1/', + 'http://10.0.2.2:8180/api/v1']) { + const got = A.normalizeApiBase(v); + assert.ok(!got.includes('/api/v1/api/v1'), + `★ ${v} 被补成了 ${got} —— 重复补前缀是 WebUI 侧真发生过的 bug(请求全 404)`); + assert.equal(got, A.stripTrailingSlashes(v), `${v} 已经带 /api/v1 ⇒ 只允许去尾斜杠,内容不动`); + } + assert.equal(A.normalizeApiBase(''), '', '空串保持空("没填"与"填错"要能分开)'); +}); + +test('★ 去尾斜杠不许吃掉协议里的 //(朴素的 while(endsWith("/")) 会把 https:// 咬成 https:)', () => { + assert.equal(A.stripTrailingSlashes('https://'), 'https://', + '协议分隔符的两个斜杠不是"尾部斜杠" —— 咬掉一个就变成 `https:`,请求直接报 URL 非法'); + assert.equal(A.stripTrailingSlashes('https://host///'), 'https://host', '路径尾斜杠该去干净'); + assert.equal(A.stripTrailingSlashes(' https://host/ '), 'https://host', '先 trim 再去斜杠'); + // 下游守卫:`https://` 这种"协议有了、域名没了"的输入必须被判非法,不能悄悄放过去 + assert.equal(A.validateApiBase('https://').ok, false, '`https://` 没有域名 ⇒ 必须判非法'); +}); + +test('★ 校验:合法/非法分得开,非法提示**自带修法**(用户照着那一行就能改对)', () => { + assert.equal(A.validateApiBase('https://mail.jianfgit.xyz/api/v1').ok, true, '标准地址要判合法'); + assert.equal(A.validateApiBase('http://192.168.2.60:8180/api/v1').ok, true, + '局域网明文直连是**有意保留的合法通道** ⇒ 不许判非法'); + const okOne = A.validateApiBase('https://mail.jianfgit.xyz'); + assert.equal(okOne.ok, true, '缺 /api/v1 只是要**补**,不是"非法"'); + assert.equal(okOne.base, 'https://mail.jianfgit.xyz/api/v1', 'ok 时 base 必须是归一化后的地址'); + assert.equal(okOne.error, '', 'ok 时不该有错误文案'); + + const badOnes = ['', ' ', 'mail.jianfgit.xyz', 'https://', 'https://mail.jianfgit.xyz /api/v1']; + for (const v of badOnes) { + const r = A.validateApiBase(v); + assert.equal(r.ok, false, `★ ${JSON.stringify(v)} 该判非法`); + assert.ok(r.error.length > 0, `★ ${JSON.stringify(v)} 判非法却没给文案 ⇒ 用户只看到"登录失败"`); + assert.ok(r.error.includes('https://') && r.error.includes('api/v1'), + `★ 提示要**自带修法**(指出目标形状 https://域名/api/v1),实际:${r.error}`); + } +}); + +test('★ 明文 http 只对"公网"告警;内网/本机地址不许误报(否则局域网直连被吓回去)', () => { + const pub = A.validateApiBase('http://mail.example.com/api/v1'); + assert.equal(pub.ok, true, '明文不是非法值(局域网直连要用),只是要提醒'); + assert.ok(pub.warning.length > 0, '★ 公网明文 http 必须告警:登录凭据会明文发出去,而用户不会自己想到'); + + for (const v of ['http://192.168.2.60:8180/api/v1', 'http://10.0.2.2:8180/api/v1', + 'http://localhost:8180/api/v1', 'http://127.0.0.1:8180/api/v1', 'https://mail.example.com/api/v1']) { + assert.equal(A.validateApiBase(v).warning, '', + `★ ${v} 不该告警 —— 内网明文 / 已经 https,告警多了就没人看了(这也是登录页那条提示被忽略的方式)`); + } + assert.equal(A.isPrivateHost('172.31.0.1'), true, '172.16-31 段也是内网'); + assert.equal(A.isPrivateHost('172.32.0.1'), false, '172.32 已经出了内网段,别把公网当内网'); +}); + +test('★ 404 必须自己写文案(服务端只会回 404 page not found),且提示里点名 /api/v1', () => { + const url = 'https://mail.jianfgit.xyz/auth/login'; + const d = A.describeFailure(404, 0, '404 page not found', url); + assert.equal(d.kind, 'http404', '404 要单独分类 —— 它不是"网络不通"'); + assert.ok(d.message.includes('/api/v1'), + `★ 404 的真实含义几乎总是"地址少了 /api/v1",文案必须点出来,实际:${d.message}`); + assert.ok(d.message.includes(url), '要把实际请求的 URL 打出来,用户才知道自己填的地址拼成了什么'); + const bare = A.describeFailure(404, 0, '', url); + assert.ok(bare.message.includes('/api/v1'), '服务端没给文案时也要说清(不能退化成"HTTP 404")'); +}); + +test('★ 其它 HTTP 状态让服务端的文案说话(回归保护:登录失败的原因不能被通用文案顶掉)', () => { + const d = A.describeFailure(401, 0, '用户名或密码错误', 'u'); + assert.equal(d.message, '用户名或密码错误', + '★ 服务端说"用户名或密码错误"比客户端编的任何话都准;用通用文案回退它 = 用户再也看不到失败原因'); + assert.equal(d.kind, 'http401', '分类仍要能区分 401'); + assert.ok(A.describeFailure(403, 0, '', 'u').message.includes('403'), '没有服务端文案时要给出状态码'); + assert.ok(A.describeFailure(500, 0, '', 'u').message.includes('500'), '5xx 同样'); +}); + +test('★ 网络层失败按 SDK 错误码分类(码取自本机 @ohos.net.http.d.ts,不是猜的)', () => { + const cases = [ + [2300005, 'dns'], [2300006, 'dns'], + [2300007, 'refused'], [2300028, 'timeout'], + [2300058, 'tls'], [2300059, 'tls'], [2300060, 'tls'], [2300077, 'tls'], + [2300997, 'cleartext'], [2300998, 'blocked-domain'], + [2300003, 'bad-url'], [2300094, 'native-auth'] + ]; + for (const [code, kind] of cases) { + const d = A.describeFailure(0, code, 'raw english', 'https://mail.jianfgit.xyz/api/v1'); + assert.equal(d.kind, kind, `错误码 ${code} 该归到 ${kind}`); + assert.ok(d.message.length > 6, `错误码 ${code} 的文案太短:${d.message}`); + assert.ok(!d.message.includes('raw english'), + `★ 错误码 ${code} 把英文原文当人话给出了(用户看不懂):${d.message}`); + } + // 域名解析失败要能指名是哪个域名 —— 否则用户不知道该去查哪一条 + const dns = A.describeFailure(0, 2300006, 'Couldn\'t resolve host name', 'https://nope.example/api/v1'); + assert.ok(dns.message.includes('nope.example'), '★ 要说清"解析不出哪个域名",否则用户只能猜'); + // 明文被禁 / 证书不受信要给可执行的下一步(这两条是自建部署最常见的两个坑) + assert.ok(A.describeFailure(0, 2300997, '', 'http://a/api/v1').message.includes('https://'), + '明文被禁 ⇒ 提示里要有"改用 https://"这个动作'); + assert.ok(A.describeFailure(0, 2300060, '', 'https://a/api/v1').message.includes('证书'), + '证书不受信 ⇒ 提示要指向证书,而不是笼统的"网络错误"'); + // 未知码:不许把原始信息吞掉(日志与用户看到的应该是同一件事) + const unknown = A.describeFailure(0, 12345, 'weird failure', 'https://a/api/v1'); + assert.equal(unknown.kind, 'network', '未知码归到 network'); + assert.ok(unknown.message.includes('12345') && unknown.message.includes('weird failure'), + '★ 未知错误码要把码与原文带出来 —— 否则现场没线索,只能让用户复现'); +}); + +test('★ hostOf:去协议/路径/端口(失败文案要靠它指名地址)', () => { + assert.equal(A.hostOf('https://mail.jianfgit.xyz/api/v1'), 'mail.jianfgit.xyz'); + assert.equal(A.hostOf('http://192.168.2.60:8180/api/v1'), '192.168.2.60', '端口不是主机名的一部分'); + assert.equal(A.hostOf('http://[::1]:8080/x'), '[::1]', '方括号 IPv6 不能按第一个冒号切'); +}); + +/* ───────────────────────── 来源判据:形状 + 接线 ───────────────────────── */ + +test('★ 默认地址自己就必须是"能通的那一个":https + 完整 /api/v1(把源码里的值喂给真逻辑判)', () => { + const cfg = read('common/Config.ets'); + const m = /DEFAULT_API_BASE: string = '([^']+)'/.exec(cfg); + assert.ok(m, 'Config.ets 里要能找到 DEFAULT_API_BASE 的字面量'); + const value = m[1]; + assert.ok(value.startsWith('https://'), + `★ 默认地址是 ${value} —— 明文默认会让"连不上"变成一个用户无从判断的状态;` + + '公网用 https(域名解析到同一台机,内网走 https 也到得了)'); + assert.equal(A.normalizeApiBase(value), value, + '★ 默认地址必须**已经**是归一化后的形态(少了 /api/v1 的默认值 = 开箱即 404)'); + const emu = /EMULATOR_HOST_BASE: string = '([^']+)'/.exec(cfg); + assert.ok(emu && A.normalizeApiBase(emu[1]) === emu[1], + '模拟器备用地址也要带 /api/v1(它走的同一个拼接逻辑)'); +}); + +test('★ 归一化的唯一闸口:ApiClient 的 init 与 setBase 都经过 normalizeApiBase', () => { + const src = read('api/ApiClient.ets'); + const setBaseIdx = src.indexOf('setBase(base: string)'); + assert.ok(setBaseIdx > 0, 'ApiClient 要有 setBase'); + const setBaseBody = src.slice(setBaseIdx, src.indexOf('getToken()', setBaseIdx) > 0 + ? src.indexOf('getToken()', setBaseIdx) : setBaseIdx + 400); + assert.ok(/this\.apiBase = normalizeApiBase\(/.test(setBaseBody), + '★ setBase 直接赋值 = 登录页/设置页/多账号切换三条路各自漏;' + + '**正确修法**:在这里归一化(一个闸口)。'); + assert.ok(/this\.apiBase = normalizeApiBase\(stored\)/.test(src), + '★ init 读 preferences 时也要归一化 —— 老装机里已经躺着一条少了 /api/v1 的坏地址,' + + '光改默认值救不了它(**最常见的错误修法**:只改 DEFAULT_API_BASE 就交差)'); +}); + +test('★ 两个"用户手填地址"的入口都校验,且坏地址**不落库**(顺序即含义)', () => { + const login = read('pages/LoginPage.ets'); + /* + * ★ 必须切出**方法体**再判(变异验证抓出来的): + * 第一版我只判了整个文件里有 `applyServerAddr()` —— 而**删掉 doLogin 里的那句调用** + * 依然全绿(方法**声明**里就有这个字串)。那正是 §6.7 说的"判了形状、没判路径": + * 校验方法写得好好的、就是没人调,等于没做。 + */ + const doLoginIdx = login.indexOf('async doLogin()'); + assert.ok(doLoginIdx > 0, 'LoginPage 要有 doLogin'); + const nextMember = login.indexOf('applyServerAddr(): boolean', doLoginIdx); + const doLoginBody = login.slice(doLoginIdx, nextMember > 0 ? nextMember : login.indexOf('build()', doLoginIdx)); + assert.ok(/if \(!this\.applyServerAddr\(\)\)/.test(doLoginBody), + '★ doLogin 里没有"地址非法就 return"的守卫 ⇒ 用户填的坏地址会直接拿去发请求,' + + '而且**还会被持久化**(**正确修法**:在 setBase/persistBase 之前先 applyServerAddr() 并早退)'); + const guardIdx = doLoginBody.indexOf('applyServerAddr()'); + const persistIdx = doLoginBody.indexOf('persistBase'); + assert.ok(persistIdx > 0, '登录页仍要持久化地址'); + assert.ok(guardIdx < persistIdx, + '★ 校验必须在 persist 之前 —— 顺序反了就是把坏地址存进去,下次启动带着它"无法连接"把用户锁在外面'); + assert.ok(!/while\s*\(v\.length > 0 && v\.endsWith\('\/'\)\)/.test(login), + '★ 登录页不许再自己写一份"去尾斜杠"(同一个事实两份实现必然漂移)'); + + // 闸口本身:applyServerAddr 必须真的调 validateApiBase(否则守卫是个空动作、永远放行) + const applyIdx = login.indexOf('applyServerAddr(): boolean'); + const applyBody = login.slice(applyIdx, login.indexOf('build()', applyIdx)); + assert.ok(/validateApiBase\(/.test(applyBody), + '★ applyServerAddr 里没有 validateApiBase ⇒ 那个守卫是空的(**最常见的错误修法**:' + + '为了让判据过而只保留一个同名方法,里面什么都不验)'); + + const settings = read('pages/SettingsPage.ets'); + const sValidateIdx = settings.indexOf('validateApiBase'); + const addIdx = settings.indexOf('manager.addAccount('); + assert.ok(sValidateIdx > 0, '★ 添加账号对话框同样要校验(它是第二个手填地址的入口)'); + assert.ok(addIdx > sValidateIdx, + '★ 校验必须在 addAccount 之前 —— 多账号库直接喂 SseService(base + /events/stream),' + + '坏地址存进去等于这条账号的实时通道永久连不上,界面上还看不出来'); + assert.ok(!/normalizeServer\(/.test(settings), '设置页自己的 normalizeServer 应已删除(改走共用逻辑)'); + + const client = read('api/ApiClient.ets'); + assert.ok(/nativeCode: number = 0/.test(client) && /ApiError\(0, failure\.message, nativeCode\)/.test(client), + '★ 网络层失败要把 BusinessError.code 带进 ApiError(没有它就无法分类成人话)'); +}); + +test('★ 内网明文通道:network_config.json 就在文档规定的位置、按文档结构开明文白名单', () => { + assert.ok(existsSync(NET_CFG), + `★ 缺 ${NET_CFG}。**正确修法**:放在 resources/base/profile/network_config.json` + + '(官方文档《使用HTTP访问网络·明文HTTP访问权限配置说明》给的就是这个固定位置与文件名,' + + '**不需要**在 module.json5 里写引用)。'); + let cfg = null; + try { + cfg = JSON.parse(readFileSync(NET_CFG, 'utf8')); + } catch (e) { + assert.fail(`network_config.json 不是合法 JSON(会被打进包里但读不出来):${e.message}`); + } + const sec = cfg['network-security-config']; + assert.ok(sec && Array.isArray(sec['domain-config']) && sec['domain-config'].length > 0, + '★ 结构要是 network-security-config.domain-config[](文档给的那一套键;' + + 'Android 那套 cleartextTrafficPermitted 在 base-config 下的写法也支持,但域名白名单用的就是这个形状)'); + const allowed = sec['domain-config'][0].cleartextTrafficPermitted; + assert.equal(allowed, true, + '★ 内网白名单必须真的允许明文,否则局域网直连会被系统直接拒掉(错误码 2300997)'); + const names = (sec['domain-config'][0].domains || []).map((d) => d.name); + assert.ok(names.includes('192.168.2.60'), + `★ 白名单里要有服务端所在的内网地址(当前:${JSON.stringify(names)})`); + assert.ok(names.includes('10.0.2.2'), '模拟器 NAT 地址也要在里面(联调那条通道)'); +}); + +test('★ 归一化只有一份实现:全仓没有第二处自己拼 /api/v1,也没有第二处去尾斜杠', () => { + const files = sourceFiles(); + // §6:扫目录的判据要能自证"扫到了东西"(改名/只扫一个子目录会让它变成空判据而全绿) + assert.ok(files.length >= 15, `只扫到 ${files.length} 个源文件,扫描范围不对(应当 ≥15)`); + for (const sub of ['pages', 'common', 'model', 'api']) { + assert.ok(files.some((f) => rel(f).startsWith(sub + '/')), `扫描范围漏了 ${sub}/`); + } + const ALLOW = [{ file: 'model/ApiBase.ts', why: '归一化的唯一实现(其他文件只许引用它)' }]; + const appendRe = /(\+\s*['"]\/api\/v1['"])|(['"]\/api\/v1['"]\s*\+)/; + const trimRe = /while\s*\([^)]*endsWith\(\s*'\/'\s*\)/; + const bad = []; + for (const f of files) { + if (ALLOW.some((a) => rel(f) === a.file)) continue; + const src = code(f); + if (appendRe.test(src)) bad.push(`${rel(f)}:自己拼接 '/api/v1'`); + if (trimRe.test(src)) bad.push(`${rel(f)}:自己写"去尾斜杠"循环`); + } + assert.deepEqual(bad, [], + `★ 归一化出现了第二实现(漂移的起点):\n ${bad.join('\n ')}\n` + + ' **正确修法**:import { normalizeApiBase } from \'…/model/ApiBase\' 用它;' + + ' **最常见的错误修法**:把这条判据的 ALLOW 加上自己的文件(那等于把"只有一份"废掉)。'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 5803842..b9314a5 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -81,6 +81,10 @@ const SUITE = [ ['test/align-refs.test.mjs', [], 3], ['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8], ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13], + // 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 / + // 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。 + // 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。 + ['test/harmony-apibase.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13], ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23], ['test/debt-visibility.test.mjs', [], 1], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4], diff --git a/client/harmony/entry/src/main/ets/api/AccountManager.ets b/client/harmony/entry/src/main/ets/api/AccountManager.ets index e4a5e82..f03969d 100644 --- a/client/harmony/entry/src/main/ets/api/AccountManager.ets +++ b/client/harmony/entry/src/main/ets/api/AccountManager.ets @@ -5,6 +5,7 @@ */ import { preferences } from '@kit.ArkData'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { normalizeApiBase } from '../model/ApiBase'; const DOMAIN = 0x0001; const TAG = 'AccountManager'; @@ -111,7 +112,12 @@ export class AccountManager { async addAccount(server: string, username: string, token: string, displayName: string): Promise { const account: AccountInfo = new AccountInfo(); account.id = this.generateId(); - account.server = server; + /* + * ★ 归一化落库(不靠调用方记得先做):多账号库是**第二份地址来源**, + * 它直接喂 `SseService`(`base + '/events/stream'`)。少了 `/api/v1` 的地址 + * 存进去,这条账号的实体推送通道会永久连不上,而界面上什么都看不出来。 + */ + account.server = normalizeApiBase(server); account.username = username; account.token = token; account.displayName = displayName.length > 0 ? displayName : username; diff --git a/client/harmony/entry/src/main/ets/api/ApiClient.ets b/client/harmony/entry/src/main/ets/api/ApiClient.ets index d92b1b6..8811aed 100644 --- a/client/harmony/entry/src/main/ets/api/ApiClient.ets +++ b/client/harmony/entry/src/main/ets/api/ApiClient.ets @@ -6,6 +6,7 @@ import { http } from '@kit.NetworkKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { DEFAULT_API_BASE, PREF_KEY_API_BASE, PREF_KEY_TOKEN } from '../common/Config'; +import { normalizeApiBase, describeFailure, FailureText } from '../model/ApiBase'; import { preferences } from '@kit.ArkData'; const DOMAIN = 0x0001; @@ -15,11 +16,17 @@ const TAG = 'AgentMailClient'; export class ApiError extends Error { code: number = 0; message: string = ''; + /** + * 底层 `BusinessError.code`(网络层错误码,如 2300006 域名解析失败); + * HTTP 层错误为 0。用来把失败分类成人话(见 `model/ApiBase.ts` 的 `describeFailure`)。 + */ + nativeCode: number = 0; - constructor(code: number, message: string) { + constructor(code: number, message: string, nativeCode?: number) { super(message); this.code = code; this.message = message; + this.nativeCode = nativeCode ?? 0; } } @@ -63,10 +70,16 @@ export class ApiClient { async init(): Promise { try { const pref = await preferences.getPreferences(this.context, 'agentmail'); - this.apiBase = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string; + /* + * ★ 这里必须**归一化**,不能原样信 preferences: + * 早期版本或用户手填过 `https://域名/`(少了 `/api/v1`)时,那条坏地址已经 + * 躺在本机存储里 —— 只改 DEFAULT_API_BASE 救不了老装机,必须在读回来的那一刻治好。 + */ + const stored: string = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string; + this.apiBase = normalizeApiBase(stored); this.token = pref.getSync(PREF_KEY_TOKEN, '') as string; } catch (e) { - this.apiBase = DEFAULT_API_BASE; + this.apiBase = normalizeApiBase(DEFAULT_API_BASE); this.token = ''; } } @@ -75,8 +88,12 @@ export class ApiClient { return this.apiBase; } + /** + * 设置 base。**归一化的唯一闸口** —— 页面(登录页/设置页/多账号切换)都走这里, + * 于是"少了 /api/v1"不可能从任何一个入口漏进来。 + */ setBase(base: string): void { - this.apiBase = base; + this.apiBase = normalizeApiBase(base); } getToken(): string { @@ -150,15 +167,16 @@ export class ApiClient { return JSON.parse(rawText) as T; } - // 错误归一化:从服务端 {"error": "..."} 取文案 - let message: string = 'HTTP ' + code; + // 错误归一化:从服务端 {"error": "..."} 取文案(服务端说的一定比客户端编的准) + let serverMessage: string = ''; try { const parsed = JSON.parse(rawText) as Record; if (parsed['error'] !== undefined) { - message = parsed['error']; + serverMessage = parsed['error']; } } catch (e) { - message = rawText.length > 0 ? rawText : ('HTTP ' + code); + // 不是 JSON(例如 Go 默认的 `404 page not found`) + serverMessage = rawText.length > 0 && rawText.length <= 200 ? rawText : ''; } if (code === 401) { @@ -166,15 +184,23 @@ export class ApiClient { this.clearAuth(); } - throw new ApiError(code, message); + /* + * ★ 说人话(见 model/ApiBase.ts):404 交给 `describeFailure` 自己写文案 + * —— 服务端只会回 `404 page not found`,那不是给用户看的,而 404 的真实 + * 含义几乎总是"地址少了 /api/v1"。其它状态码仍然让服务端的文案说话。 + */ + const failure: FailureText = describeFailure(code, 0, serverMessage, url); + throw new ApiError(code, failure.message); } catch (e) { if (e instanceof ApiError) { throw e as ApiError; } const be = e as BusinessError; - const msg: string = be.message !== undefined ? be.message : '网络错误'; - hilog.error(DOMAIN, TAG, '← %{public}s failed: %{public}s', url, msg); - throw new ApiError(0, msg); + const nativeCode: number = be.code !== undefined ? be.code : 0; + const rawMessage: string = be.message !== undefined ? be.message : ''; + const failure: FailureText = describeFailure(0, nativeCode, rawMessage, url); + hilog.error(DOMAIN, TAG, '← %{public}s failed: native=%{public}d %{public}s', url, nativeCode, rawMessage); + throw new ApiError(0, failure.message, nativeCode); } // 不复用销毁:会话级实例保留 Cookie } diff --git a/client/harmony/entry/src/main/ets/api/SseService.ets b/client/harmony/entry/src/main/ets/api/SseService.ets index b0f0731..3290062 100644 --- a/client/harmony/entry/src/main/ets/api/SseService.ets +++ b/client/harmony/entry/src/main/ets/api/SseService.ets @@ -7,6 +7,7 @@ import { http } from '@kit.NetworkKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { AccountManager, AccountInfo } from './AccountManager'; +import { normalizeApiBase } from '../model/ApiBase'; const DOMAIN = 0x0001; const TAG = 'SseService'; @@ -95,7 +96,7 @@ export class SseService { this.connections.set(accountId, conn); } - conn.server = server; + conn.server = normalizeApiBase(server); conn.token = token; conn.connected = true; this.setConnStatus(conn, 'connecting'); diff --git a/client/harmony/entry/src/main/ets/common/Config.ets b/client/harmony/entry/src/main/ets/common/Config.ets index fc5ffc7..c6af9aa 100644 --- a/client/harmony/entry/src/main/ets/common/Config.ets +++ b/client/harmony/entry/src/main/ets/common/Config.ets @@ -3,13 +3,31 @@ * apiBase 可运行时修改(设置页/登录页),persist 到 preferences */ -/** 默认联调 Gateway(pi 提供,GUI 联调专用) */ -export const DEFAULT_API_BASE: string = 'http://192.168.2.60:8180/api/v1'; +/** + * 默认地址:**HTTPS + 公网域名**(2026-09-15 改)。 + * + * 原来是 `http://192.168.2.60:8180/api/v1` —— 明文 + 写死内网 IP,两个问题: + * 1. 它只在"手机与这台机在同一网段"时可用,出门/换网就永远连不上, + * 而错误还只说"无法连接",用户无从判断; + * 2. 明文 http 会把登录凭据暴露在链路上。 + * + * 域名解析到的就是同一台机(`mail.jianfgit.xyz` → 192.168.2.60), + * 所以本机/内网调试**不需要**为了速度牺牲 TLS:走 https 也能到。 + * 真要直连内网明文,用设置页改成 `http://192.168.2.60:8180/api/v1`(见 network_config.json)。 + * + * ★ 末尾的 `/api/v1` **不能省**:`ApiClient` 里的路径都是相对它的 + * (`'/auth/login'`、`'/me/sessions'`…),省掉就变成 `https://域名/auth/login` ⇒ 404。 + * 归一化与校验在 `model/ApiBase.ts`(用户手填的地址走那里,判据跑那一份)。 + */ +export const DEFAULT_API_BASE: string = 'https://mail.jianfgit.xyz/api/v1'; -/** 模拟器 NAT 访问宿主机地址(备用,若 LAN 直连不通) */ +/** + * 模拟器 NAT 访问宿主机地址(备用,LAN 直连不通时用)。 + * 明文 http 是**有意为之**:这里连的是宿主机上的联调 Gateway,只在模拟器内网里可达。 + */ export const EMULATOR_HOST_BASE: string = 'http://10.0.2.2:8180/api/v1'; /** preferences 存储键 */ export const PREF_KEY_API_BASE: string = 'api_base'; export const PREF_KEY_TOKEN: string = 'user_token'; -export const PREF_KEY_USERNAME: string = 'username'; \ No newline at end of file +export const PREF_KEY_USERNAME: string = 'username'; diff --git a/client/harmony/entry/src/main/ets/model/ApiBase.ts b/client/harmony/entry/src/main/ets/model/ApiBase.ts new file mode 100644 index 0000000..d6eb5fa --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/ApiBase.ts @@ -0,0 +1,330 @@ +/* + * AgentMail 鸿蒙客户端 —— 服务器地址(apiBase)归一化/校验 + 网络失败人话化。 + * + * 纯逻辑、无 `@ohos` 依赖 —— 与 `Wallpaper.ts` / `PushContract.ts` / `Calendar.ts` + * 同模式,所以判据(`client/electron/test/harmony-apibase.test.mjs`)能用 node 直接跑它, + * 不需要设备。**逻辑只有这一份**:页面、`ApiClient`、`SseService` 都引它, + * 不许各自再写一段 trim/补后缀("同一个事实两份实现"必然漂移)。 + * + * ── 为什么需要这个文件(2026-09-15 的线上症状) ── + * + * 用户报"鸿蒙客户端连不上服务器",但服务端 `https://mail.jianfgit.xyz/health` 是 200。 + * 两个独立的坑叠在一起: + * + * 1. `DEFAULT_API_BASE` 是 `http://192.168.2.60:8180/api/v1`(明文 + 写死内网 IP), + * 而 `mail.jianfgit.xyz` 解析到的就是这台内网机 ⇒ 默认值对"不在这个网段的手机"永远不通; + * 2. `apiBase` 是**完整 API 前缀**(`ApiClient` 里拼的是 `'/auth/login'` 这类相对路径), + * 所以用户在设置页只填 `https://mail.jianfgit.xyz/` 时,请求变成 + * `https://mail.jianfgit.xyz/auth/login` ⇒ 服务端 **404**, + * 而客户端只把它显示成"无法连接",用户没法自助 —— **这才是最可能的直接原因**。 + * + * 于是这里给出三件事:归一化(补 `/api/v1`、去尾斜杠)、校验(可执行的错误文案)、 + * 以及把失败分类成人话(DNS / 连不上 / 超时 / 证书 / 明文被禁 / 404 少前缀)。 + */ + +/** + * apiBase 的固定后缀。`ApiClient` 的所有路径都是相对它的(`'/auth/login'`、`'/me/sessions'`…), + * `SseService` 拼的是 `base + '/events/stream'`。**少了它全部 404。** + */ +export const API_PATH_SUFFIX: string = '/api/v1'; + +/** 地址校验结果。`base` 是**归一化后**的地址(`ok` 时可直接持久化/使用)。 */ +export interface ApiBaseCheck { + ok: boolean; + base: string; + /** 不合法时的**可执行**提示(例如「地址应是 https://域名/api/v1」);合法时为空串 */ + error: string; + /** 合法但值得提醒(例如明文 http 指向公网);无则空串 */ + warning: string; +} + +/** 失败分类 + 给人看的文案。`kind` 是机器可判的,`message` 是给用户的。 */ +export interface FailureText { + kind: string; + message: string; +} + +/** + * 去首尾空白 + 去尾部斜杠(可多个),**不会**吃掉协议里的 `//`。 + * + * 单独抽出来是因为"尾部斜杠"这件事有两种写法都会踩: + * `https://mail.jianfgit.xyz/` → 不去掉就拼成 `//auth/login`; + * 而朴素的 `while (endsWith('/'))` 会把 `https://` 咬成 `https:`(协议分隔符的两个斜杠)。 + * 所以这里只允许**在 `://` 之后**去斜杠。 + */ +export function stripTrailingSlashes(raw: string): string { + const trimmed: string = raw.trim(); + const schemeSep: number = trimmed.indexOf('://'); + const keep: number = schemeSep >= 0 ? schemeSep + 3 : 0; + let end: number = trimmed.length; + while (end > keep && trimmed.charAt(end - 1) === '/') { + end = end - 1; + } + return trimmed.substring(0, end); +} + +/** + * 归一化:去空白、去尾斜杠,**末尾没有 `/api/v1` 就补上**。 + * + * 已经是 `/api/v1` 的**原样不动**(不重复补 —— 补成 `/api/v1/api/v1` 是 + * 另一侧(WebUI)真发生过的 bug,鸿蒙侧不能重演)。 + * + * 只做大小写**精确**匹配:路径是大小写敏感的,`/API/V1` 在服务端就是 404, + * 这里替它"猜"只会让用户更晚发现问题。 + */ +export function normalizeApiBase(raw: string): string { + const base: string = stripTrailingSlashes(raw); + if (base.length === 0) { + return ''; + } + if (base.endsWith(API_PATH_SUFFIX)) { + return base; + } + return base + API_PATH_SUFFIX; +} + +/** 字符串里有没有空白(地址中间夹空格是最常见的粘贴事故) */ +export function containsWhitespace(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const c: string = s.charAt(i); + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + return true; + } + } + return false; +} + +/** 取主机名(去掉协议、路径与端口);`[::1]:8180` 这种带方括号的形式也认得 */ +export function hostOf(base: string): string { + const schemeSep: number = base.indexOf('://'); + const rest: string = schemeSep >= 0 ? base.substring(schemeSep + 3) : base; + const slash: number = rest.indexOf('/'); + const authority: string = slash >= 0 ? rest.substring(0, slash) : rest; + if (authority.startsWith('[')) { + const close: number = authority.indexOf(']'); + return close >= 0 ? authority.substring(0, close + 1) : authority; + } + const colon: number = authority.indexOf(':'); + return colon >= 0 ? authority.substring(0, colon) : authority; +} + +/** 内网/本机地址:这些地方用明文 http 是**有意为之**(局域网直连、模拟器 NAT),不是事故 */ +export function isPrivateHost(host: string): boolean { + const h: string = host.toLowerCase(); + if (h.length === 0) { + return false; + } + if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') { + return true; + } + if (h.startsWith('127.') || h.startsWith('192.168.') || h.startsWith('10.')) { + return true; + } + if (h.startsWith('172.')) { + const parts: string[] = h.split('.'); + if (parts.length >= 2) { + const second: number = Number(parts[1]); + if (second >= 16 && second <= 31) { + return true; + } + } + } + return false; +} + +/** + * 校验用户填的地址。**只做"能不能当 apiBase"这件事**,不联网探测 + * (联网探测是登录动作本身要干的事;这里不该多发一次请求)。 + * + * 错误文案一律**自带修法**:用户看到的那一行就是他能照做的动作。 + */ +export function validateApiBase(raw: string): ApiBaseCheck { + const trimmed: string = raw.trim(); + if (trimmed.length === 0) { + return { + ok: false, + base: '', + error: '请填写服务器地址,例如 https://mail.jianfgit.xyz/api/v1', + warning: '' + }; + } + const base: string = normalizeApiBase(trimmed); + const lower: string = base.toLowerCase(); + if (!lower.startsWith('http://') && !lower.startsWith('https://')) { + return { + ok: false, + base: base, + error: '地址要以 http:// 或 https:// 开头,例如 https://mail.jianfgit.xyz/api/v1' + + '(不自动替你补 https —— 协议选错会把凭据送到明文通道上)', + warning: '' + }; + } + const schemeSep: number = base.indexOf('://'); + const rest: string = base.substring(schemeSep + 3); + if (containsWhitespace(rest)) { + return { + ok: false, + base: base, + error: '地址里不能有空格,例如 https://mail.jianfgit.xyz/api/v1', + warning: '' + }; + } + const host: string = hostOf(base); + if (host.length === 0) { + return { + ok: false, + base: base, + error: '地址里缺少域名,例如 https://mail.jianfgit.xyz/api/v1', + warning: '' + }; + } + let warning: string = ''; + if (lower.startsWith('http://') && !isPrivateHost(host)) { + warning = '这是明文 http:// 地址:登录凭据会明文发送,公网地址请改用 https://'; + } + return { ok: true, base: base, error: '', warning: warning }; +} + +/** HTTP 状态码 → 分类(只用于 `kind`;文案见 `describeFailure`) */ +export function kindOfStatus(status: number): string { + if (status === 401) { + return 'http401'; + } + if (status === 403) { + return 'http403'; + } + if (status === 404) { + return 'http404'; + } + if (status >= 500) { + return 'http5xx'; + } + if (status >= 400) { + return 'http4xx'; + } + return 'http'; +} + +/** + * 网络层错误码 → 分类。 + * + * 错误码来源是**本机 SDK 的 d.ts**(`@ohos.net.http` 的 `@throws` 清单, + * API 23 那份),不是猜的:2300006 域名、2300007 连接、2300028 超时、 + * 2300997 明文被禁、2300998 域被拒、2300058/59/60/77 SSL。 + */ +export function kindOfNativeCode(nativeCode: number): string { + if (nativeCode === 2300005 || nativeCode === 2300006) { + return 'dns'; + } + if (nativeCode === 2300007) { + return 'refused'; + } + if (nativeCode === 2300028) { + return 'timeout'; + } + if (nativeCode === 2300997) { + return 'cleartext'; + } + if (nativeCode === 2300998) { + return 'blocked-domain'; + } + if (nativeCode === 2300058 || nativeCode === 2300059 || nativeCode === 2300060 || nativeCode === 2300077) { + return 'tls'; + } + if (nativeCode === 2300001 || nativeCode === 2300003) { + return 'bad-url'; + } + if (nativeCode === 2300094) { + return 'native-auth'; + } + return 'network'; +} + +/** + * 把一次失败说成人话。 + * + * 分流规则(顺序有意义): + * 1. `status === 404` → **一定是"地址不对"这一族**:服务端连路由都没匹配上。 + * 这一条必须自己给文案(服务端只会回 `404 page not found`,那不是给人看的), + * 并且把"应当以 /api/v1 结尾"写进提示 —— 用户踩的就是这个坑。 + * 2. 其它 HTTP 状态 → **服务端说了算**:`{"error":"用户名或密码错误"}` 这类文案 + * 比客户端编的任何话都准(回退了它,登录失败的原因就看不见了)。 + * 3. `status === 0` → 请求根本没到服务端,按 `nativeCode` 分类给**可执行**的排查建议。 + */ +export function describeFailure(status: number, nativeCode: number, serverMessage: string, url: string): FailureText { + if (status === 404) { + let message: string = 'HTTP 404:服务端没有 ' + url + ' 这个接口。' + + '地址应当是完整的 API 前缀、以 /api/v1 结尾(例如 https://mail.jianfgit.xyz/api/v1)。'; + if (serverMessage.length > 0) { + message = message + '服务端回复:' + serverMessage; + } + return { kind: 'http404', message: message }; + } + if (status > 0) { + if (serverMessage.length > 0) { + return { kind: kindOfStatus(status), message: serverMessage }; + } + return { kind: kindOfStatus(status), message: '请求失败(HTTP ' + status + ')' }; + } + + const kind: string = kindOfNativeCode(nativeCode); + const host: string = hostOf(url); + if (kind === 'dns') { + return { + kind: kind, + message: '域名解析失败:解析不出 ' + host + '。请检查域名拼写、手机是否连着网络;' + + '若填的是内网地址,请确认手机与服务器在同一网段。' + }; + } + if (kind === 'refused') { + return { + kind: kind, + message: '连不上服务器(' + host + ' 拒绝了连接或不可达):请确认服务已启动、端口正确,' + + '并且手机与服务器在同一网络。' + }; + } + if (kind === 'timeout') { + return { + kind: kind, + message: '连接超时(' + host + '):网络可达但服务没在规定时间内响应,' + + '请确认服务正常、或换个网络重试。' + }; + } + if (kind === 'tls') { + return { + kind: kind, + message: 'HTTPS 证书不受信任或 TLS 握手失败(错误码 ' + nativeCode + '):' + + '请检查证书是否过期/自签;自签证书需要在 network_config.json 里预置 CA。' + }; + } + if (kind === 'cleartext') { + return { + kind: kind, + message: '系统禁止明文 HTTP(2300997):请改用 https://,' + + '或为内网地址在 network_config.json 里放行明文。' + }; + } + if (kind === 'blocked-domain') { + return { + kind: kind, + message: '该域名被系统安全策略拒绝访问(2300998):请检查 network_config.json 的域名配置。' + }; + } + if (kind === 'bad-url') { + return { + kind: kind, + message: '地址格式不对(错误码 ' + nativeCode + '):地址应形如 https://域名/api/v1。' + }; + } + if (kind === 'native-auth') { + return { + kind: kind, + message: '服务端要求认证或认证被拒(2300094):请检查用户密钥是否有效。' + }; + } + const raw: string = serverMessage.length > 0 ? serverMessage : '未知错误'; + return { + kind: kind, + message: '网络请求失败(错误码 ' + nativeCode + '):' + raw + }; +} diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 69bfeb6..d12020e 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -10,12 +10,15 @@ import { AccountManager } from '../api/AccountManager'; import { SseService } from '../api/SseService'; import { Me } from '../model/Models'; import { DEFAULT_API_BASE, EMULATOR_HOST_BASE } from '../common/Config'; +import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { hilog } from '@kit.PerformanceAnalysisKit'; @Entry @Component struct LoginPage { @State serverAddr: string = DEFAULT_API_BASE; + /** 地址合法但值得提醒(例如公网明文 http)—— 直接渲染在输入框下面,不靠一次性 toast */ + @State addrWarning: string = ''; @State username: string = ''; @State password: string = ''; @State userKey: string = ''; @@ -84,6 +87,16 @@ struct LoginPage { if (this.loading) { return; } + /* + * ★ 先把地址归一化/校验,**不合法就地拦下**(2026-09-15 的故障就是这个入口漏的)。 + * 要点两条: + * 1. 少了 `/api/v1` 的地址会被补全(用户只填 `https://域名/` 是本次最可能的直接原因); + * 2. 不合法的地址**绝不**写进 preferences —— 否则下次启动会带着一个坏地址 + * 去"无法连接",而用户已经忘了自己填过什么。 + */ + if (!this.applyServerAddr()) { + return; + } const c: ApiClient | null = this.client; const a: AuthApi | null = this.authApi; if (c === null || a === null) { @@ -91,9 +104,9 @@ struct LoginPage { } this.loading = true; try { - // 保存服务器地址(覆盖默认) - await c.setBase(this.stripTrailingSlash(this.serverAddr)); - await c.persistBase(this.stripTrailingSlash(this.serverAddr)); + // 保存服务器地址(覆盖默认)。setBase 是同步的,不要 await(lint: await-thenable) + c.setBase(this.serverAddr); + await c.persistBase(this.serverAddr); hilog.info(0x0001, 'LoginPage', 'base saved, calling login'); let user: Me; @@ -130,12 +143,21 @@ struct LoginPage { } } - stripTrailingSlash(s: string): string { - let v: string = s.trim(); - while (v.length > 0 && v.endsWith('/')) { - v = v.substring(0, v.length - 1); + /** + * 校验并归一化登录页上填的地址(逻辑在 `model/ApiBase.ts`,判据跑那一份)。 + * 返回 false 时**已经把可执行的提示弹给用户**,调用方直接 return 即可。 + */ + applyServerAddr(): boolean { + const check: ApiBaseCheck = validateApiBase(this.serverAddr); + this.addrWarning = check.warning; + if (!check.ok) { + // 改用户填进去的原文是帮倒忙(他会以为自己看错了),只提示不动它 + this.getUIContext().getPromptAction().showToast({ message: check.error }); + return false; } - return v; + // 归一化结果回填:用户能看见"我填的地址被补成了什么",而不是默默变 + this.serverAddr = check.base; + return true; } build() { @@ -154,11 +176,21 @@ struct LoginPage { .margin({ top: 100, bottom: 48 }) // 服务器地址 - TextInput({ placeholder: '服务器地址', text: this.serverAddr }) + TextInput({ placeholder: '服务器地址(含 /api/v1)', text: this.serverAddr }) .width('85%') .height(48) .margin({ bottom: 12 }) - .onChange((v: string) => { this.serverAddr = v; }) + .onChange((v: string) => { + this.serverAddr = v; + // 边输边算:合规性提示在按下"登录"之前就看得见 + this.addrWarning = validateApiBase(v).warning; + }) + + if (this.addrWarning.length > 0) { + Text('⚠ ' + this.addrWarning) + .fontSize(11).fontColor(Theme.warnFg) + .width('85%').margin({ bottom: 12 }) + } // 切换登录方式 Row() { @@ -194,9 +226,11 @@ struct LoginPage { this.doLogin(); }) - // 模拟器备选地址提示 - Text('模拟器 NAT 不通时用 ' + EMULATOR_HOST_BASE) + // 地址提示:默认值就是推荐值;末尾 /api/v1 必须带(少了会 404,而 404 以前只显示"无法连接") + Text('地址要写到 /api/v1 为止,例如 ' + DEFAULT_API_BASE) .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 20 }) + Text('模拟器 NAT 不通时用 ' + EMULATOR_HOST_BASE) + .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 4 }) if (this.loggedIn) { // 登录成功 → 进入主界面(占位,后续 M2 替换) diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 69f1fc8..39718a2 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -13,6 +13,7 @@ import { AppearanceSnapshot, statusLabel } from '../model/Appearance'; import { MeApi } from '../api/AdminApi'; import { AdminUser } from '../model/Models'; import { isAdminRole } from '../model/AdminUsers'; +import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { BackgroundPicker } from '../common/BackgroundPicker'; @Entry @@ -209,14 +210,6 @@ struct SettingsPage { this.activeId = manager.getActiveId(); } - normalizeServer(server: string): string { - let normalized: string = server.trim(); - while (normalized.length > 0 && normalized.endsWith('/')) { - normalized = normalized.substring(0, normalized.length - 1); - } - return normalized; - } - async switchTo(accountId: string): Promise { const manager: AccountManager | null = this.acctMgr; const client: ApiClient | null = this.client; @@ -267,13 +260,26 @@ struct SettingsPage { return; } const displayName: string = this.newDisplayName.trim(); - const server: string = this.normalizeServer(this.newServer); const token: string = this.newToken.trim(); const optionalUsername: string = this.newUsername.trim(); - if (displayName.length === 0 || server.length === 0 || token.length === 0) { - this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称、Gateway 地址和 user_key' }); + if (displayName.length === 0 || token.length === 0) { + this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称和 user_key' }); return; } + /* + * ★ 地址单独校验(与登录页同一份逻辑): + * - 少了 `/api/v1` 会被补全 —— 这个入口与登录页一样是"用户手填地址"的地方; + * - 不合法就**不往多账号库里存**:那个库会直接喂给 `SseService`(`base + '/events/stream'`), + * 存进去的坏地址会让这条账号的实时通道永久连不上,而界面上看不出来。 + */ + const check: ApiBaseCheck = validateApiBase(this.newServer); + if (!check.ok) { + this.getUIContext().getPromptAction().showToast({ message: check.error }); + return; + } + const server: string = check.base; + // 回填:让用户看见地址被补成了什么,而不是默默变 + this.newServer = server; this.adding = true; try { @@ -449,7 +455,7 @@ struct SettingsPage { .width('100%').height(44).margin({ bottom: 10 }) .onChange((value: string) => { this.newDisplayName = value; }) - TextInput({ placeholder: 'Gateway 地址(必填)', text: this.newServer }) + TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer }) .width('100%').height(44).margin({ bottom: 10 }) .onChange((value: string) => { this.newServer = value; }) diff --git a/client/harmony/entry/src/main/resources/base/profile/network_config.json b/client/harmony/entry/src/main/resources/base/profile/network_config.json new file mode 100644 index 0000000..75b705e --- /dev/null +++ b/client/harmony/entry/src/main/resources/base/profile/network_config.json @@ -0,0 +1,23 @@ +{ + "network-security-config": { + "domain-config": [ + { + "domains": [ + { + "name": "192.168.2.60", + "include-subdomains": false + }, + { + "name": "10.0.2.2", + "include-subdomains": false + }, + { + "name": "localhost", + "include-subdomains": false + } + ], + "cleartextTrafficPermitted": true + } + ] + } +}