From 0a3b0f3ca07353f6aefa1b98cce88c6b346c5a3e Mon Sep 17 00:00:00 2001 From: dadao Date: Wed, 28 Oct 2026 23:38:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E7=9C=9F=E6=9C=BA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8D=E4=B8=8A=E2=80=94=E2=80=94=E6=98=8E=E6=96=87?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E8=A2=AB=E7=BD=91=E7=BB=9C=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=8B=A6=E4=BD=8F=EF=BC=8C=E4=B8=94=E5=9C=B0=E5=9D=80=E5=86=99?= =?UTF-8?q?=E6=AD=BB=E5=9C=A8=E6=A8=A1=E6=8B=9F=E5=99=A8=E7=BD=91=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈"app 端登录不上去"。查下来是两个叠在一起的问题,都不是"后台没开": 1) 主清单设了 android:networkSecurityConfig(白名单只有 10.0.2.2 / 127.0.0.1 / localhost), 而它的优先级高于 debug 清单里的 android:usesCleartextTraffic="true"。 所以真机连 http://192.168.8.x:8100 会被系统拦成 CLEARTEXT communication not permitted, 表现成登录无反应;模拟器因为地址在白名单里,一切正常,很容易误判。 → 新增 app/src/debug/res/xml/network_security_config.xml,debug 放开明文; release 仍只有白名单,线上必须 HTTPS。 2) 后端地址编译期写死 BuildConfig.API_BASE = http://10.0.2.2:8100,那是模拟器专用地址。 → 登录页加「服务器:… / 改地址」:可改、可「测试连接」、存在 TokenStore 里, 改一次就记住,不用重新打包。地址缺失协议时自动补 http://。 顺带把网络异常翻成人话:以前 OkHttp 抛 IOException 原样冒到界面, 现在统一成「连不上 <地址> —— 具体该去查什么」,并区分了 "10.0.2.2 是模拟器地址"、"127.0.0.1 在手机上指的是手机自己"两种情况; 连不上时自动展开地址面板。 后端这边没有代码改动,但真机要用 --host 0.0.0.0 启动(原来只监听 127.0.0.1), 已写进 DEVELOPMENT.md 与 android/README.md,附防火墙放行命令。 实测:模拟器内把地址改成 http://192.168.8.151:8100 → 测试连接「连得通」→ 取码(开发环境验证码回显)→ 登录成功进入工作区列表。 --- 2.Design/DEVELOPMENT.md | 44 ++++++- android/README.md | 17 +++ .../debug/res/xml/network_security_config.xml | 16 +++ .../src/main/java/com/dadao/task/data/Api.kt | 70 ++++++++--- .../java/com/dadao/task/data/TokenStore.kt | 16 +++ .../java/com/dadao/task/ui/LoginScreen.kt | 110 ++++++++++++++++++ 6 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 android/app/src/debug/res/xml/network_security_config.xml diff --git a/2.Design/DEVELOPMENT.md b/2.Design/DEVELOPMENT.md index 390539c..22a8c75 100644 --- a/2.Design/DEVELOPMENT.md +++ b/2.Design/DEVELOPMENT.md @@ -129,7 +129,47 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d > 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来, > 上面是配置与脚本,M3 开工前必须先把它跑通。 -## 8. 常见坑 +## 8. 让安卓真机连上本地后端 + +手机连不上后端,90% 是这三件事之一,按顺序查: + +**① 后端有没有监听所有网卡** + +```bash +cd backend +.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8100 +``` + +`--host 127.0.0.1`(uvicorn 默认)只对本机可见,手机必然连不上。用 `0.0.0.0` 之后: + +```powershell +Invoke-RestMethod http://192.168.8.151:8100/health # 换成你自己的局域网 IP +``` + +**② 手机上填的地址对不对** + +APK 里默认写的是 `http://10.0.2.2:8100`(模拟器映射宿主机的地址),真机连不上它。 +在**登录页 → 服务器:… → 改地址**里填电脑的局域网 IP,点「测试连接」看到「连得通」再登录。 +地址存在本地,改一次即可,不需要重新打包。 + +**③ 防火墙** + +Windows 上 python.exe 首次监听端口时如果没弹「允许访问」,手机连过来的包会被丢。 +以**管理员**运行一次: + +```powershell +New-NetFirewallRule -DisplayName "DadaoTask backend 8100" -Direction Inbound ` + -Action Allow -Protocol TCP -LocalPort 8100 -Profile Private +``` + +**关于明文 HTTP**:Android 从 9 开始默认禁止明文流量。主清单里配了 +`network_security_config`(只放行 `10.0.2.2/127.0.0.1/localhost`),而 +`android:usesCleartextTraffic` 一旦和它同时存在就不再生效——这就是"真机怎么都登不上、 +但模拟器正常"的根因。所以 debug 变体单独放了一份 +`android/app/src/debug/res/xml/network_security_config.xml` 允许明文; +release 仍走白名单,线上域名必须 HTTPS。 + +## 9. 常见坑 | 现象 | 原因 | 处理 | |---|---|---| @@ -180,7 +220,7 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d - **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。 - `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。 -## 9. 提交前自检清单 +## 10. 提交前自检清单 1. `pytest` 全绿,`ruff` 无告警。 2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。 diff --git a/android/README.md b/android/README.md index 60ba2d2..2a9da35 100644 --- a/android/README.md +++ b/android/README.md @@ -38,6 +38,23 @@ cd android gradle :app:assembleDebug # 或 ./gradlew(wrapper 需 Android Studio 补齐) ``` +## 连不上后端?先看这三件事 + +1. **后端要监听所有网卡**:`uvicorn app.main:app --host 0.0.0.0 --port 8100`。 + 默认的 `127.0.0.1` 只有本机可见,手机连不上。 +2. **地址要填对**:包里的默认值是模拟器地址 `http://10.0.2.2:8100`。 + 真机在登录页点「改地址」填电脑的局域网 IP(如 `http://192.168.8.151:8100`), + 点「测试连接」看到「连得通」再登录。改一次会记住,不用重新打包。 +3. **防火墙**:Windows 没弹过「允许访问」的话,手机包会被丢。管理员 PowerShell 执行 + `New-NetFirewallRule -DisplayName "DadaoTask backend 8100" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8100 -Profile Private`。 + +还有一个容易查半天的点:主清单配了 `network_security_config`(白名单只有 +`10.0.2.2/127.0.0.1/localhost`),这种情况下 `android:usesCleartextTraffic="true"` **不生效**, +真机连 `http://192.168.8.x` 会被系统拦成 "CLEARTEXT communication not permitted", +表现就是"点了登录没反应/登录不上去",而模拟器一切正常。 +所以 `android/app/src/debug/res/xml/network_security_config.xml` 单独给 debug 放开了明文, +release 依旧只走 HTTPS 白名单。 + ## 连哪个后端 `android/local.properties`(不入库): diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..5112d49 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 9635075..b0c53d7 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -13,6 +13,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException import java.util.concurrent.TimeUnit /** 与后端信封一致的错误类型。 */ @@ -309,7 +310,46 @@ class Api(private val tokens: TokenStore) { private val jsonMedia = "application/json; charset=utf-8".toMediaType() // 与 backend/app/config.py 的 api_prefix 保持一致 - private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1" + private val origin: String + get() = (tokens.apiBase ?: BuildConfig.API_BASE).trimEnd('/') + + private val base: String get() = origin + "/api/v1" + + /** 当前生效的后端地址(不含 /api/v1),登录页展示与修改用。 */ + fun currentOrigin(): String = origin + + /** 运行时切换后端地址;传 null 恢复编译期默认值。 */ + fun setOrigin(value: String?) { + tokens.apiBase = value?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() } + } + + /** 探活:`/health` 不带 `/api/v1` 前缀。用来区分"后端没起"和"地址填错"。 */ + suspend fun ping(): String = withContext(Dispatchers.IO) { + try { + http.newCall(Request.Builder().url("$origin/health").build()).execute().use { r -> + if (!r.isSuccessful) throw ApiException(1, "$origin 返回 HTTP ${r.code}") + "ok" + } + } catch (e: IOException) { + throw unreachable(e) + } + } + + /** + * 网络层异常统一翻成人话。 + * + * 真机最常见的坑:包里默认地址是模拟器专用的 10.0.2.2, + * 手机连不上它,表现却只是"登录失败",所以把地址和下一步动作直接写进提示。 + */ + private fun unreachable(e: IOException): ApiException { + val hint = when { + origin.contains("10.0.2.2") -> "这是模拟器专用地址,真机请改成电脑的局域网地址" + origin.contains("127.0.0.1") || origin.contains("localhost") -> + "手机上的 127.0.0.1 是手机自己,请改成电脑的局域网地址" + else -> "检查后端是否在运行、手机与电脑是否连同一个网络、防火墙是否放行" + } + return ApiException(1, "连不上 $origin —— $hint") + } /** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */ fun logout() { @@ -532,19 +572,23 @@ class Api(private val tokens: TokenStore) { tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") } } - http.newCall(builder.build()).execute().use { response -> - val text = response.body?.string().orEmpty() - if (text.isBlank()) { - throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})") + try { + http.newCall(builder.build()).execute().use { response -> + val text = response.body?.string().orEmpty() + if (text.isBlank()) { + throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})") + } + val envelope = runCatching { json.decodeFromString>(text) }.getOrElse { + throw ApiException(1500, "响应格式异常(HTTP ${response.code})") + } + if (envelope.code != 0) { + if (envelope.code == 2002 && auth) tokens.clear() + throw ApiException(envelope.code, envelope.message, envelope.requestId) + } + envelope.data ?: throw ApiException(1500, "响应缺少 data") } - val envelope = runCatching { json.decodeFromString>(text) }.getOrElse { - throw ApiException(1500, "响应格式异常(HTTP ${response.code})") - } - if (envelope.code != 0) { - if (envelope.code == 2002 && auth) tokens.clear() - throw ApiException(envelope.code, envelope.message, envelope.requestId) - } - envelope.data ?: throw ApiException(1500, "响应缺少 data") + } catch (e: IOException) { + throw unreachable(e) } } } diff --git a/android/app/src/main/java/com/dadao/task/data/TokenStore.kt b/android/app/src/main/java/com/dadao/task/data/TokenStore.kt index 27ce6a4..a365d35 100644 --- a/android/app/src/main/java/com/dadao/task/data/TokenStore.kt +++ b/android/app/src/main/java/com/dadao/task/data/TokenStore.kt @@ -31,8 +31,24 @@ class TokenStore(context: Context) { val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank() + /** + * 后端地址,含协议与端口、不含 `/api/v1`。 + * + * 为空表示用编译期默认值([com.dadao.task.BuildConfig.API_BASE],模拟器是 10.0.2.2)。 + * 真机联调时手机连不到 10.0.2.2,必须在登录页改成本机局域网地址, + * 所以这里做成可运行时覆盖并持久化,而不是重新打包。 + */ + var apiBase: String? + get() = prefs.getString(KEY_API_BASE, null)?.takeIf { it.isNotBlank() } + set(value) { + prefs.edit().apply { + if (value.isNullOrBlank()) remove(KEY_API_BASE) else putString(KEY_API_BASE, value) + }.apply() + } + private companion object { const val KEY_ACCESS = "access" const val KEY_REFRESH = "refresh" + const val KEY_API_BASE = "api_base" } } diff --git a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt index 0e693b6..680e277 100644 --- a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt @@ -50,6 +50,42 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + // 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。 + var serverOpen by remember { mutableStateOf(false) } + var address by remember { mutableStateOf(api.currentOrigin()) } + var probe by remember { mutableStateOf(null) } + var probeOk by remember { mutableStateOf(false) } + + fun normalize(input: String): String { + val text = input.trim().ifBlank { "http://" } + val withScheme = if (text.startsWith("http://") || text.startsWith("https://")) text else "http://$text" + return withScheme.trimEnd('/') + } + + fun saveAddress() { + api.setOrigin(normalize(address)) + address = api.currentOrigin() + probeOk = false + probe = "已保存:${api.currentOrigin()}" + } + + fun probeServer() { + scope.launch { + busy = true + probe = null + runCatching { api.ping() } + .onSuccess { + probeOk = true + probe = "连得通:${api.currentOrigin()}" + } + .onFailure { + probeOk = false + probe = (it as? ApiException)?.message ?: "连不上" + } + busy = false + } + } + LaunchedEffect(seconds) { if (seconds > 0) { delay(1000) @@ -68,6 +104,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { } .onFailure { val c = (it as? ApiException)?.code ?: 0 + if (c == 1) serverOpen = true error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" } } busy = false @@ -84,6 +121,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { } .onFailure { val c = (it as? ApiException)?.code ?: 0 + if (c == 1) serverOpen = true error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" } } busy = false @@ -195,6 +233,78 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { onClick = { submit() }, ) + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "服务器:${api.currentOrigin()}", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + modifier = Modifier.weight(1f), + ) + GhostButton( + text = if (serverOpen) "收起" else "改地址", + enabled = !busy, + onClick = { + serverOpen = !serverOpen + if (serverOpen) address = api.currentOrigin() + probe = null + }, + ) + } + + if (serverOpen) { + Spacer(Modifier.height(8.dp)) + Text( + "真机填电脑的局域网地址,例如 http://192.168.8.151:8100;模拟器用 http://10.0.2.2:8100。", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = address, + onValueChange = { address = it.take(80) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("后端地址") }, + placeholder = { Text("http://192.168.8.151:8100", color = InkMuted) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + GhostButton( + text = "测试连接", + enabled = !busy, + onClick = { + saveAddress() + probeServer() + }, + ) + Spacer(Modifier.width(10.dp)) + PrimaryButton( + text = "保存", + enabled = !busy, + onClick = { saveAddress() }, + ) + } + val p = probe + if (p != null) { + Spacer(Modifier.height(10.dp)) + SoftPill( + p, + if (probeOk) Sage else Rust, + if (probeOk) SageSoft else RustSoft, + Modifier.fillMaxWidth(), + ) + } + } + Spacer(Modifier.height(28.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { listOf(