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(