用户反馈"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 → 测试连接「连得通」→ 取码(开发环境验证码回显)→ 登录成功进入工作区列表。
This commit is contained in:
+42
-2
@@ -129,7 +129,47 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d
|
|||||||
> 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来,
|
> 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来,
|
||||||
> 上面是配置与脚本,M3 开工前必须先把它跑通。
|
> 上面是配置与脚本,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**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
|
- **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
|
||||||
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
|
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
|
||||||
|
|
||||||
## 9. 提交前自检清单
|
## 10. 提交前自检清单
|
||||||
|
|
||||||
1. `pytest` 全绿,`ruff` 无告警。
|
1. `pytest` 全绿,`ruff` 无告警。
|
||||||
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
|
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
|
||||||
|
|||||||
@@ -38,6 +38,23 @@ cd android
|
|||||||
gradle :app:assembleDebug # 或 ./gradlew(wrapper 需 Android Studio 补齐)
|
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`(不入库):
|
`android/local.properties`(不入库):
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
debug 变体专用(覆盖 main 里的同名资源)。
|
||||||
|
|
||||||
|
为什么需要它:主清单设了 android:networkSecurityConfig,这个属性和
|
||||||
|
android:usesCleartextTraffic 同时存在时以网络安全配置为准——也就是说
|
||||||
|
debug 清单里写的 usesCleartextTraffic="true" 会被忽略,只放行白名单里的
|
||||||
|
10.0.2.2 / 127.0.0.1 / localhost。真机连 http://192.168.8.x:8100 属于白名单外,
|
||||||
|
会被系统拦成 "CLEARTEXT communication not permitted",表现就是"登录不上去"。
|
||||||
|
|
||||||
|
debug 一律允许明文,方便连局域网里的后端;release 仍然只有 main 里的白名单,
|
||||||
|
线上域名必须 HTTPS。
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
@@ -13,6 +13,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/** 与后端信封一致的错误类型。 */
|
/** 与后端信封一致的错误类型。 */
|
||||||
@@ -309,7 +310,46 @@ class Api(private val tokens: TokenStore) {
|
|||||||
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
// 与 backend/app/config.py 的 api_prefix 保持一致
|
// 与 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 是无状态的,没有注销接口。 */
|
/** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
@@ -532,19 +572,23 @@ class Api(private val tokens: TokenStore) {
|
|||||||
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||||
}
|
}
|
||||||
|
|
||||||
http.newCall(builder.build()).execute().use { response ->
|
try {
|
||||||
val text = response.body?.string().orEmpty()
|
http.newCall(builder.build()).execute().use { response ->
|
||||||
if (text.isBlank()) {
|
val text = response.body?.string().orEmpty()
|
||||||
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
|
if (text.isBlank()) {
|
||||||
|
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
val envelope = runCatching { json.decodeFromString<Envelope<T>>(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<Envelope<T>>(text) }.getOrElse {
|
} catch (e: IOException) {
|
||||||
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
|
throw unreachable(e)
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,24 @@ class TokenStore(context: Context) {
|
|||||||
|
|
||||||
val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank()
|
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 {
|
private companion object {
|
||||||
const val KEY_ACCESS = "access"
|
const val KEY_ACCESS = "access"
|
||||||
const val KEY_REFRESH = "refresh"
|
const val KEY_REFRESH = "refresh"
|
||||||
|
const val KEY_API_BASE = "api_base"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,42 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
||||||
|
var serverOpen by remember { mutableStateOf(false) }
|
||||||
|
var address by remember { mutableStateOf(api.currentOrigin()) }
|
||||||
|
var probe by remember { mutableStateOf<String?>(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) {
|
LaunchedEffect(seconds) {
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
@@ -68,6 +104,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
val c = (it as? ApiException)?.code ?: 0
|
val c = (it as? ApiException)?.code ?: 0
|
||||||
|
if (c == 1) serverOpen = true
|
||||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
||||||
}
|
}
|
||||||
busy = false
|
busy = false
|
||||||
@@ -84,6 +121,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
val c = (it as? ApiException)?.code ?: 0
|
val c = (it as? ApiException)?.code ?: 0
|
||||||
|
if (c == 1) serverOpen = true
|
||||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
||||||
}
|
}
|
||||||
busy = false
|
busy = false
|
||||||
@@ -195,6 +233,78 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
onClick = { submit() },
|
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))
|
Spacer(Modifier.height(28.dp))
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
Reference in New Issue
Block a user