跨端: 修复: 鸿蒙客户端"连不上服务器"—— 地址补 /api/v1 + 失败分类成人话(含网络白名单固化)
症状 鸿蒙客户端(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 解析修好才会被套件真正计数。
This commit is contained in:
@ -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<AccountInfo> {
|
||||
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;
|
||||
|
||||
@ -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<void> {
|
||||
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<string, string>;
|
||||
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
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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';
|
||||
export const PREF_KEY_USERNAME: string = 'username';
|
||||
|
||||
330
client/harmony/entry/src/main/ets/model/ApiBase.ts
Normal file
330
client/harmony/entry/src/main/ets/model/ApiBase.ts
Normal file
@ -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
|
||||
};
|
||||
}
|
||||
@ -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 替换)
|
||||
|
||||
@ -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<void> {
|
||||
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; })
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user