Compare commits

...

4 Commits

Author SHA1 Message Date
AndyChen1024
84980f0fa9 chore: 添加 gitignored 凭据文件模板 2026-09-11 13:05:10 +08:00
AndyChen1024
b14f883e3e feat(auth): 手机号验证码登录(会话模型 + 登录页 + 导航接入) 2026-09-11 13:05:10 +08:00
AndyChen1024
762e3975ca docs: AGENTS.md 补充三端关联项目说明 2026-09-11 13:05:10 +08:00
AndyChen1024
18f6ddd449 docs: 补充 V4.0 需求规格说明书(小程序+后端) 2026-09-11 13:05:10 +08:00
17 changed files with 996 additions and 34 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ gradle/gradle-daemon-jvm.properties
*.iml *.iml
.idea/ .idea/
local.properties local.properties
secrets.properties
# OS # OS
.DS_Store .DS_Store

View File

@@ -2,6 +2,16 @@
「明缘进阶场」的 Android 客户端(`com.mingyuan.android_client`),对接 `wechat-backend` 提供的 auth / checkin / plan / course-vote / survey / feedback / profile API。当前处于**基础建设阶段**:多模块骨架已就绪,各 feature 为空占位,尚未接入真实后端。 「明缘进阶场」的 Android 客户端(`com.mingyuan.android_client`),对接 `wechat-backend` 提供的 auth / checkin / plan / course-vote / survey / feedback / profile API。当前处于**基础建设阶段**:多模块骨架已就绪,各 feature 为空占位,尚未接入真实后端。
## 关联项目
本仓库是「明缘进阶场」三端之一。分析功能/业务时可跨仓库参考对应实现。
| 端 | 仓库地址 | 本地路径 |
|------|------|------|
| Android(本仓库) | https://git.muguaclub.cn/mingyuan/android-client | 当前目录(`android-client`) |
| 后端 | https://git.muguaclub.cn/mingyuan/wechat-backend | `../wechat-backend` |
| 小程序 | https://git.muguaclub.cn/mingyuan/wechat-clock-mini | `../wechat-clock-mini` |
## 快速开始 ## 快速开始
```bash ```bash

View File

@@ -21,6 +21,8 @@ import androidx.navigation.navArgument
import com.mingyuan.android_client.core.designsystem.theme.TabActive import com.mingyuan.android_client.core.designsystem.theme.TabActive
import com.mingyuan.android_client.core.designsystem.theme.TabBackground import com.mingyuan.android_client.core.designsystem.theme.TabBackground
import com.mingyuan.android_client.core.designsystem.theme.TabInactive import com.mingyuan.android_client.core.designsystem.theme.TabInactive
import com.mingyuan.android_client.feature.auth.AUTH_ROUTE
import com.mingyuan.android_client.feature.auth.AuthScreen
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.checkin.CheckinScreen import com.mingyuan.android_client.feature.checkin.CheckinScreen
import com.mingyuan.android_client.feature.home.HOME_ROUTE import com.mingyuan.android_client.feature.home.HOME_ROUTE
@@ -74,18 +76,18 @@ fun AppNavHost() {
HomeScreen( HomeScreen(
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) }, onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) }, onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }, onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
) )
} }
composable(CHECKIN_ROUTE) { composable(CHECKIN_ROUTE) {
CheckinScreen(onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }) CheckinScreen(onNavigateToAuth = { navController.navigate(AUTH_ROUTE) })
} }
composable(PLAN_ROUTE) { composable(PLAN_ROUTE) {
PlanScreen( PlanScreen(
onNavigateToPlanSubmit = { type, periodKey -> onNavigateToPlanSubmit = { type, periodKey ->
navController.navigate(planSubmitRoute(type, periodKey)) navController.navigate(planSubmitRoute(type, periodKey))
}, },
onNavigateToProfile = { navigateToTab(PROFILE_ROUTE) }, onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
) )
} }
composable( composable(
@@ -100,8 +102,14 @@ fun AppNavHost() {
) { ) {
PlanSubmitScreen(onBack = { navController.popBackStack() }) PlanSubmitScreen(onBack = { navController.popBackStack() })
} }
composable(AUTH_ROUTE) {
AuthScreen(onLoggedIn = { navController.popBackStack() })
}
composable(PROFILE_ROUTE) { composable(PROFILE_ROUTE) {
ProfileScreen(onLoggedOut = { navigateToTab(HOME_ROUTE) }) ProfileScreen(
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
onLoggedOut = { navigateToTab(HOME_ROUTE) },
)
} }
} }
} }

View File

@@ -1,5 +1,10 @@
package com.mingyuan.android_client.core.data.di package com.mingyuan.android_client.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository
import com.mingyuan.android_client.core.data.repository.FakeHomeRepository import com.mingyuan.android_client.core.data.repository.FakeHomeRepository
import com.mingyuan.android_client.core.data.repository.FakePlanRepository import com.mingyuan.android_client.core.data.repository.FakePlanRepository
@@ -12,7 +17,9 @@ import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository import com.mingyuan.android_client.core.domain.repository.SessionRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@@ -39,4 +46,11 @@ abstract class DataModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository
companion object {
@Provides
@Singleton
fun provideSessionDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("session") }
}
} }

View File

@@ -1,25 +1,71 @@
package com.mingyuan.android_client.core.data.repository package com.mingyuan.android_client.core.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.mingyuan.android_client.core.domain.repository.LoginResult
import com.mingyuan.android_client.core.domain.repository.SessionRepository import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
import com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Local-mock session implementation. It simulates the "phone + SMS code" login flow and persists
* the token via DataStore so the login survives process restarts (mirroring the mini-program's
* `wx.getStorageSync('token')`). Swap this for a network-backed implementation when the backend
* endpoints are ready.
*
* Mock credentials: phone `13812341234`, code `123456`.
*/
@Singleton @Singleton
class FakeSessionRepository @Inject constructor() : SessionRepository { class FakeSessionRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : SessionRepository {
private val _isLoggedIn = MutableStateFlow(true) private companion object {
override val isLoggedIn: Flow<Boolean> = _isLoggedIn val TOKEN_KEY = stringPreferencesKey("token")
const val MOCK_SMS_CODE = "123456"
val PHONE_PATTERN = Regex("^1\\d{10}$")
// Mirrors the backend's "phone must be pre-provisioned" constraint.
val PHONE_WHITELIST = setOf("13812341234")
}
override suspend fun login() { private var pendingCode: String? = null
delay(300) private var pendingPhone: String? = null
_isLoggedIn.value = true
override val session: Flow<Session?> = dataStore.data
.map { prefs -> prefs[TOKEN_KEY]?.let(::Session) }
override suspend fun requestSmsCode(phone: String): SmsCodeResult {
if (!PHONE_PATTERN.matches(phone)) {
return SmsCodeResult.Failure("请输入正确的手机号")
}
delay(500)
pendingPhone = phone
pendingCode = MOCK_SMS_CODE
return SmsCodeResult.Success
}
override suspend fun login(phone: String, code: String): LoginResult {
if (phone != pendingPhone || code != pendingCode) {
return LoginResult.Failure("验证码错误")
}
if (phone !in PHONE_WHITELIST) {
return LoginResult.Failure("手机号未匹配,无法登录")
}
delay(500)
dataStore.edit { it[TOKEN_KEY] = "mock-token-$phone" }
pendingPhone = null
pendingCode = null
return LoginResult.Success
} }
override suspend fun logout() { override suspend fun logout() {
delay(300) dataStore.edit { it.remove(TOKEN_KEY) }
_isLoggedIn.value = false
} }
} }

View File

@@ -8,5 +8,5 @@ android {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(libs.androidx.datastore.preferences) api(libs.androidx.datastore.preferences)
} }

View File

@@ -1,11 +1,30 @@
package com.mingyuan.android_client.core.domain.repository package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
sealed interface SmsCodeResult {
data object Success : SmsCodeResult
data class Failure(val message: String) : SmsCodeResult
}
sealed interface LoginResult {
data object Success : LoginResult
data class Failure(val message: String) : LoginResult
}
interface SessionRepository { interface SessionRepository {
val isLoggedIn: Flow<Boolean> /** The current session, or `null` when logged out. */
val session: Flow<Session?>
suspend fun login() /** Derived convenience kept for the existing feature ViewModels (home/checkin/plan/profile). */
val isLoggedIn: Flow<Boolean>
get() = session.map { it != null }
suspend fun requestSmsCode(phone: String): SmsCodeResult
suspend fun login(phone: String, code: String): LoginResult
suspend fun logout() suspend fun logout()
} }

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.model
/**
* A logged-in session. Deliberately minimal for now: the token is the only thing needed to decide
* whether the user is logged in, and the user profile is still provided by [ProfileRepository].
* When the real backend lands, this grows a `user: AuthUser` field and the local data source grows
* a user cache.
*/
data class Session(
val token: String,
)

View File

@@ -0,0 +1,657 @@
# 明缘云道场小程序 + 后端 API · 需求规格说明书 V4.0
> 基于 `miniprogram/`(微信小程序前端)和 `server/`(FastAPI 后端)实际代码反向整理。
> 与 V2.0/V3.0(涵盖 Web 主站 + 学员网站)平行存在;本文档 **仅覆盖这两个子系统**。
>
> · 适用范围:`miniprogram/`、`server/`、`server/migrations/`
> · 反向工程日期:2026-09-10
> · 反向基线:`git` 工作树(HEAD = 当前)
---
## 目录
1. 文档信息
2. 项目概述
3. 用户角色与权限
4. 术语表
5. 小程序前端(miniprogram/)
6. 后端 API(server/)
7. 数据模型
8. 部署与配置
9. 安全与合规
10. 非功能性需求
11. 错误约定
12. 已知问题与 TODO
---
## 1. 文档信息
| 项 | 内容 |
| ----- | --------------------------------------------------- |
| 软件名称 | 明缘云道场小程序(前端)+ 明缘进阶场 API(后端) |
| 版本 | V1.0 / V4.0 文档 |
| 平台 | 前端:微信小程序 · 后端:Linux + Python 3.9 + FastAPI |
| 关键依赖 | 微信开发者工具 · PyMySQL + DBUtils · httpx · python-dotenv |
| 客户端入口 | 微信内搜索 / 扫码进入「明缘云道场」 |
| 服务端入口 | uvicorn `server.main:app` 监听 `:8000` |
| 文档目的 | 把代码事实定型为可验收的需求规格 |
---
## 2. 项目概述
### 2.1 子系统定位
| 子系统 | 代码路径 | 角色 |
| ------ | -------------- | ---------------------------------------------- |
| 小程序前端 | `miniprogram/` | 用户交互入口(打卡 / 计划 / 个人),本地无状态,所有数据走后端 API |
| 后端 API | `server/` | 鉴权、业务逻辑、MySQL 读写、文件存储、WebView 桥接 |
| 微信生态 | 微信开放接口 | `wx.login` 换取 openid、`getuserphonenumber` 绑手机号 |
### 2.2 业务目标
为「明缘进阶场」体系的成员提供基于微信的 **轻量化学习管理工具**,核心是:
1. **每周打卡**(周日上课日)
2. **周 / 月 / 年计划** 三级管理
3. **小组统计**(学员互看,管理员全览)
4. **个人主页**(资料、头像、登录方式)
5. 内嵌 **H5 复用既有 Web 复盘系统**(通过 WebView + cookie 桥接)
### 2.3 不在范围内(明确)
- 不在前端代码内的子系统(Web 主站 `xinhuo/19qi/18qi/`、学员站)——见 V3.0
- 不做支付、不做 IM、不做推送(小程序代码中无相关痕迹)
- 不做服务端渲染 SSR
---
## 3. 用户角色与权限
### 3.1 角色矩阵
代码侧只识别两种角色(`auth_users.role` 字段):
| 角色键 | 中文 | 判定方式 | 用例 |
| -------------------------------------------------------- | -------- | ------------------------------------------------ | -------------------- |
| `teacher` | 管理员 / 老师 | `(user.role or '').strip().lower() == 'teacher'` | 看所有小组、看未分组人数、看全部打卡统计 |
| 其他(含 `student`、`member`、`leader`、`group_leader`、`admin`) | 成员 / 组长 | 非 teacher 走学员分支 | 只看自己所在小组的数据 |
> 注:`ROLE_LABELS`(`pages/profile/profile.js`)将 `student/member → 成员`、`teacher/admin → 管理员`、`leader/group_leader → 组长`,但后端权限只认 `teacher`,其他角色后端一律视作学员。
### 3.2 权限矩阵
| 接口前缀 | 学员 | 老师 |
| ------------------------------------ | ---------------- | ----------------- |
| `/api/v1/auth/*` | ✅ 自助 | ✅ 同左 |
| `/api/v1/profile` | ✅ 自己 | ✅ 自己 |
| `/api/v1/profile/avatar` | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/weeks` | ✅ | ✅ |
| `/api/v1/checkin/groups` | ✅(仅看自己小组) | ✅(看全部 + 未分组数) |
| `/api/v1/checkin/group` | 仅自己小组(传他组 → 403) | 任意小组(不传看自己) |
| `/api/v1/checkin` POST | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/list` | ✅ 自己历史 | ✅ 自己历史 |
| `/api/v1/plan/*` GET | ✅ 看自己 / 自己小组 | ✅ 看全部 + 未分组数 |
| `/api/v1/plan` POST `/checkin` | ✅ 自己当前周期 | ✅ 自己当前周期(限当前周期) |
| `/api/v1/plan/file/{sid}/...` DELETE | 仅自己 sid(403) | 仅自己 sid(403) |
| `/api/v1/webview/redirect` | 凭 token | 凭 token(所有权限同 H5) |
| `/api/v1/survey/*` | ✅ | ✅(无鉴权) |
| `/api/v1/feedback` GET | ⚠ 无鉴权(已知问题) | ⚠ 无鉴权(已知问题) |
| `/api/v1/course-vote` | ✅ 自己投 | ✅ 自己投 |
| `/api/v1/yijing/*` | ✅ | ✅ |
---
## 4. 术语表
| 术语 | 含义 |
| --------------------- | --------------------------------------------------------------------------------- |
| openid | 微信用户唯一标识,由 `code_to_openid(code)` 换取 |
| token / session token | 64 位 hex,会话标识,写入 `auth_sessions` 表(不是 JWT,名字沿用老 JWT 配置) |
| class_date | 上课日 = 每周周日(周一 ~ 周日组成的自然周) |
| period_key | 计划周期键:周=YYYY-Www、月=YYYY-MM、年=YYYY |
| 小组(group) | `auth_users.group` 字段,如「01组」 |
| display_name | 形如「01组张三」(仅当 group 形如 `NN组` 时拼接) |
| webview 桥接 | 通过 `/api/v1/webview/redirect?token=...&target=...` 把 URL 上的 token 换成 cookie 再 302 |
---
## 5. 小程序前端(miniprogram/)
### 5.1 全局结构
```
miniprogram/
├── app.js App 入口:onLaunch 调 ensureLogin → /profile
├── app.json 7 个页面 + 4 tabBar + 导航主题色
├── app.wxss 全局基础样式(背景 #FDFBF7、主色 #C67B30)
├── project.config.json
├── project.private.config.json
├── sitemap.json
├── assets/ tabBar 图标(home/checkin/plan/profile × 2)
├── utils/
│ ├── api.js request / planFileUrl / 环境切换 / 401 跳登录
│ └── auth.js ensureLogin 单例 + loginWithPhone + token 存取
└── pages/
├── home/ 首页(已登录 → 内嵌 H5;游客 → 原生介绍)
├── checkin/ 打卡(老师总览+详情 / 学员小组+个人)
├── plan/ 计划总览(周/月/年)
├── plan-submit/ 计划提交(文本 + 至多 4 张图)
├── profile/ 个人主页(资料 / 头像 / 退出)
├── webview/ 通用 WebView(域名白名单)
└── privacy/ 隐私政策 + 用户协议(静态展示)
```
### 5.2 主题色与视觉规范
| token | 值 | 用途 |
| ----- | ------------------------------ | ------------- |
| 主背景 | `#FDFBF7` | 全站冷色米白底 |
| 顶栏 | `#2C1810` 渐变 → `#6B3F24` | Hero 卡背景 |
| 主交互色 | `#C67B30` | 主按钮、未选中状态、强调色 |
| 成功 | `#2E7D32`(+ 浅底 `#E8F4EB`) | 「正常」打卡、已完成 |
| 警示 | `#C0392B`(+ 浅底 `#FDEAEA`) | 「不参加讨论」、错误页 |
| 信息 | `#3A7BD5` | 「请假」 |
| 文本主 | `#2C1810` | 标题 |
| 文本辅 | `#8B7A6A` / `#999` | 描述、时间戳 |
| 圆角 | `12rpx / 16rpx / 24rpx` | 卡片、按钮、Hero |
| 字号 | 22/24/26/28/30/32/34/40/48 rpx | 时间戳到主标题 |
### 5.3 API 客户端(utils/api.js)
#### 5.3.1 环境切换(多域名)
```js
getAccountInfoSync().miniProgram.envVersion
├── 'develop' → baseUrl = https://test.muguaclub.cn/clock
├── 'trial' → baseUrl = https://test.muguaclub.cn/clock
└── 'release' → baseUrl = https://m.muguaclub.cn/clock
```
- 三个环境对应 `STATIC_BASE_URL` + `H5_BASE_URL` 同源
#### 5.3.2 request 行为约定
| 入参 | 默认 | 说明 |
| -------- | ------ | --------------------------------------------------------- |
| `url` | 必填 | 必须以 `/api/v1` 开头 |
| `method` | `GET` | 影响幂等重试 |
| `data` | `{}` | 自动 JSON |
| `auth` | `true` | true 时自动从 storage 取 `token` 加 `Authorization: Bearer xxx` |
行为:
- **401** → 删 token + `goProfile()` 切到「我的」页(带 1.2s 防连点锁)
- **幂等方法(GET / DELETE)** 失败自动 800ms 后重试一次;命中 `domain not` 错误不重试
- **非 2xx** → reject 原 data(`{error/detail/message}`)
#### 5.3.3 planFileUrl 路径约定
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` →
`STATIC_BASE_URL + /clock/static/plan/<encoded/path>`
> 前 4 段缺失则返回空串,强制调用方重新加载。
### 5.4 登录态(utils/auth.js)
#### 5.4.1 单例 + 指数退避
- `loginPromise` 全局单例,解决 `App.onLaunch` 与首页 `onShow` 同时调 `ensureLogin` 的竞态
- `wxLogin` 内部失败重试 3 次(首次 600ms → 800ms → 1000ms)
- 失败兜底:`wechatLogin` 整个流程失败再重试 1 次(500ms 后)
- 任何情况下 `loginPromise` 完成后清空 → 让下一次重新发起(避免冷启动偶发失败后卡死)
#### 5.4.2 双入口登录
| 入口 | 触发 |
| ------ | ------------------------------------------------------------------------------------------------------------------- |
| 微信授权登录 | `ensureLogin()` → `/api/v1/auth/wechat-login` 用 code 换 token;后端返回 `need_phone:true` 时 resolve(null),前端引导到「我的」页绑定手机号 |
| 手机号登录 | `loginWithPhone(phoneCode)` → `/api/v1/auth/bind-phone` 必须同时传 `code` 和 `phone_code`;绑定成功后写 token 并返回 is_new:true |
### 5.5 页面清单
#### 5.5.1 home 首页
- **登录态判断**:`isLoggedIn = !!getToken()`
- **已登录** → `<web-view src="/api/v1/webview/redirect?token=...&target=...">`,key 自增强制重建(解决切 tab 白屏)
- **未登录** → 原生介绍页(Hero 头图 + 4 个模块卡 + 4 个产品介绍卡 + 引导登录按钮)
- 提供分享:`onShareAppMessage` / `onShareTimeline`
#### 5.5.2 checkin 打卡(核心)
**老师视图**
- `view = 'overview'`:所有小组打卡进度 + 自己的打卡状态
- `view = 'detail'`:点开某小组看每个成员是否打卡 / 状态
**学员视图**
- 顶部打卡卡:5 个状态按钮(正常/迟到/请假/不参加讨论/早退),单选切换
- `tab = 'group'`:本组成员列表
- `tab = 'personal'`:个人打卡历史(分页,每页 20 条)
**通用交互**
- 周选择器:`/api/v1/checkin/weeks` 返 2022 年到当前的所有上课周六
- 下拉刷新 + 上拉加载更多(个人历史 tab)
- 周计划 / 月计划 / 年计划的周期切换(plan 页面公用同一套 picker)
**打卡规则(来自 `routes/checkin.py`)**
- 状态:`正常 / 迟到 / 请假 / 不参加讨论 / 早退`
- 默认状态 `正常`
- 备注最大 500 字符(仅非「正常」时允许)
- 打卡窗口:上课日所在自然周(周一 ~ 周日)与当前周相同 / 未来周可打卡
#### 5.5.3 plan 计划(核心)
- 类型切换 tab:**周计划 / 月计划 / 年计划**
- 卡片:
- 自己的状态(已提交/待提交 + 当前周期)
- 「提交 / 修改」按钮,`canSubmit=false` 时 disabled
- 窗口提示(如「周四截止,已过提交时间」)
- 老师视图:所有小组列表
- 学员视图:tab = 小组(自己成员)/ 个人(历史最多 20 条)
- 周期 key:
- 周 = `YYYY-Www`(ISO 周)
- 月 = `YYYY-MM`
- 年 = `YYYY`
**提交时间窗(`routes/plan.py`)**
- 周:上一周五 00:00 ~ 本周四 23:59:59
- 月 / 年:仅当前周期可提交,历史 / 未来都禁止
#### 5.5.4 plan-submit 提交页
| 控件 | 限制 |
| -------- | ------------------------------------------------------------------ |
| textarea | maxlength 1000、disabled=!canSubmit |
| 图片 | 最多 4 张、允许后缀 `jpg/jpeg/png/gif/webp/bmp`、>1MB 自动逐级压缩 80→60→45 |
| 删除 | 调 `/api/v1/plan/file/{sid}/{plan_type}/{period}/{filename}` DELETE |
| 提交 | 文本或图片必有一项;已有记录弹覆盖确认 |
**上传协议**:`wx.uploadFile`,`formData = { plan_type, period_key }`,`name='files'`,**支持 multipart 多文件**
#### 5.5.5 profile 个人主页
**视图状态机**:`view ∈ { loading, profile, phoneLogin, loadError }`
- 已登录 + 有 token → 显示完整资料 + 头像 + 班级分组列表 + 退出登录
- 未登录 / need_phone → 显示手机号登录卡(强依赖隐私协议勾选)
- chooseAvatar:调 `/api/v1/profile/avatar` 单文件上传,avatar 文件名统一为 `avatar.{ext}`,旧头像自动清理
- 退出登录:调 `/api/v1/auth/logout`(尽力而为)+ 清本地 token + 清隐私同意标记 + reLaunch 回首页
#### 5.5.6 webview 通用 web-view
- 入参:`url`(必须 URL 编码)
- **域名白名单**(`isAllowedUrl`):仅允许 `m.muguaclub.cn` 和 `muguaclub.cn` 及其子域;非法 URL 渲染错误页
#### 5.5.7 privacy 隐私 / 协议页
- 两个静态文档:隐私政策 8 节、用户协议 8 节
- 通过 query `type` 切换:`privacy` / `agreement`
- 最后更新日期占位:**2026 年 9 月**
### 5.6 通用 UI 特性
- **骨架屏**:列表 / 表单 / 详情都有 `sk-*` 占位
- **错误页**:圆形「!」 + 失败文案 + 「重新加载」按钮
- **空态**:emoji + 简短说明
- **分享**:所有页面都实现 `onShareAppMessage` + `onShareTimeline`
---
## 6. 后端 API(server/)
### 6.1 工程骨架
```
server/
├── __init__.py
├── main.py FastAPI() + 挂 9 个 router + /api/v1/health
├── config.py 从 .env 读所有密钥 / 路径
├── db.py PyMySQL + DBUtils PooledDB,单例 per db
├── security.py encrypt_phone(hmac-sha256) + create_session_token
├── wechat.py code_to_openid + get_phone_number + _get_access_token
├── user.py make_display_name + get_class_groups + user_payload
├── routes/
│ ├── __init__.py
│ ├── auth.py /api/v1/auth/*
│ ├── checkin.py /api/v1/checkin/*
│ ├── plan.py /api/v1/plan/*
│ ├── profile.py /api/v1/profile*
│ ├── webview.py /api/v1/webview/redirect
│ ├── survey.py /api/v1/survey/* (无鉴权)
│ ├── feedback.py /api/v1/feedback/* (含仅 ADMIN_KEY 的管理接口)
│ ├── course_vote.py /api/v1/course-vote*
│ └── yijing.py /api/v1/yijing/* (从老 Flask 服务迁移)
└── migrations/
├── 001_add_wechat_openid_column.sql
├── 002_plan_perf_index.sql
├── 003_checkin_records_user_id.sql
└── 004_plan_checkins_user_id.sql
```
> ⚠ 已知 bug(`main.py:29`):`uvicorn.run("server_prd.main:app", ...)` 引用不存在的包,启动会 ImportError。
### 6.2 全局运行时依赖
| 依赖 | 作用 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| FastAPI | Web 框架 |
| uvicorn | ASGI 运行(默认 :8000,host 127.0.0.1,dev 模式 reload) |
| PyMySQL | MySQL 驱动 |
| DBUtils `PooledDB` | 连接池:`DB_POOL_MIN_CACHED=1 / MAX_CACHED=5 / MAX_CONNECTIONS=10 / MAX_USAGE=100`、ping=1、connect_timeout=5s、read_timeout=5s |
| httpx | 调用 `api.weixin.qq.com`(jscode2session / access_token / 手机号解密) |
| python-dotenv | `load_dotenv(dotenv_path, override=True)` |
| pydantic | 请求体 / 响应模型 |
### 6.3 配置项(config.py)
| 键 | 默认 | 用途 |
| ---------------------------------- | ------------------------------------------- | -------------------------------------------- |
| `WECHAT_APPID` / `WECHAT_SECRET` | 空 | 微信 API 凭据 |
| `JWT_SECRET` * | 空 | **代码里没有真正用上 JWT**,命名沿用历史,实际为 `auth_sessions` |
| `JWT_ALGORITHM` | `HS256` | 同上,遗留 |
| `JWT_TTL_SECONDS` | `90*24*3600`(90 天) | 会话有效期 |
| `ADMIN_KEY` | 空 | `/api/v1/feedback/resolve` 管理密钥 |
| `MYSQL_HOST/PORT/USER/PASSWORD/DB` | `localhost/3306/root/''/mys_info_v3` | 主业务库 |
| `SURVEY_DB` | `myxhxc` | 问卷库(独立库,独立连接池) |
| `PHONE_HMAC_KEY` | `MingYuan2026AuthKey` | 手机号 HMAC-SHA256 盐 |
| `H5_BASE_URL` | `http://127.0.0.1:8001` | WebView 跳转上游(H5 主站) |
| `FEEDBACK_DIR` | `/home/introduction/feedback` | 用户反馈 JSON + 图片目录 |
| `COURSE_VOTES_FILE` | `/home/introduction/test/course_votes.json` | 课程投票持久化文件 |
| `PLAN_ATTACH_DIR` | `/home/static/plan` | 计划附件根目录 |
| `STATIC_ROOT` / `AVATAR_DIR` | `/home/static[/avatars]` | 头像静态文件目录(nginx 直读) |
| `DB_POOL_*` | 上表 | 连接池参数(仅从 os.environ 读,不走 config) |
> 注:`config.py` 用 `os.environ.get(name, default)`,但 `db.py` 用 `os.environ.get("DB_POOL_*")` 直接读。
### 6.4 数据库连接池(db.py)
- 多库支持:`_get_pool(database)` 按 db 名缓存 pool,**双数据库**(主业务 + survey)
- `fetch_one / fetch_all / execute` 三个 API,借 / 还连接(`close()` 让连接回池)
- `close_all()` 用于测试 / reload hook
### 6.5 鉴权(`get_current_user`)
判定顺序:
1. `Authorization: Bearer xxx`(小程序 API 默认)
2. `Cookie: auth_token=xxx`(H5 走 webview 桥接后自动带)
3. 都无 → 401
查表:
- `auth_sessions WHERE token=? AND expires_at>NOW()` 拿到 `user_id`
- 再 `SELECT * FROM auth_users WHERE id=?`
### 6.6 接口约定
#### 6.6.1 /api/v1/auth
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | --------------- | --- | ------------------------------------------------------------------------------------------------ |
| POST | `/wechat-login` | 否 | `code` → openid → 查 user;命中即发 token,未命中返 `need_phone:true` |
| POST | `/bind-phone` | 否 | `code, phone_code` → 拿手机号 → HMAC 查 user;命中即 update `wechat_openid` + 发 token;首次绑定返 `is_new:true` |
| GET | `/verify` | 是 | 返回 `user_payload` |
| POST | `/logout` | 是 | 删除 session + 清 user.wechat_openid + 删 cookie |
#### 6.6.2 /api/v1/profile
| 方法 | 路径 | 说明 |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------- |
| GET | `/profile` | user_payload + `phone_display`(注意:**原文里 user 表无 phone_display 字段**,8.2 已知问题) + `wechat_avatar` + `login_type` |
| POST | `/profile/avatar` | multipart 单文件,4MB 上限,存 `avatar.{ext}`,旧头像自动删;URL `=/clock/static/avatars/{uid}/avatar{ext}` |
#### 6.6.3 /api/v1/checkin
| 方法 | 路径 | 说明 |
| ---- | ----------------------------------------- | ---------------------------------- |
| GET | `/weeks` | 2022 年至当前的所有上课周六列表 + current_week |
| GET | `/week` | **已弃用**,仅供旧客户端 |
| GET | `/groups?class_date=YYYY-MM-DD` | 老师→全部小组 + 未分组数;学员→自己小组 + my_group |
| GET | `/group?class_date=&group_name=` | 单组成员 + 各自打卡状态;学员传他组→403 |
| POST | `/checkin` `{status, class_date?, note?}` | upsert(按 user_id + class_date 唯一键) |
| GET | `/list?page=&size=` | 个人历史分页,默认 10,上限 50 |
#### 6.6.4 /api/v1/plan
| 方法 | 路径 | 说明 |
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| GET | `/periods?type=` | 周/月/年周期列表 + current_period |
| GET | `/status?type=&period_key?=` | 看本期是否已提交 + can_submit + message |
| GET | `/groups?type=&period_key?=` | 老师看全部 + 未分组;学员看自己小组 |
| GET | `/group?type=&group_name=&period_key?=` | 单组成员列表 + 各自 filled |
| POST | `/checkin` `{plan_type, period_key?, status?, content?, attachments[]?}` | upsert(按 user_id + plan_type + period_key) |
| GET | `/history?type=&page=&size=` | 个人历史分页 |
| POST | `/upload` multipart `{plan_type, period_key, files[]}` | 至多 4 文件、每文件 ≤10MB、白名单扩展名、保存路径 `attachments/{user_id}/{plan_type}/{period_key}/{filename}` |
| GET | `/file/{sid}/{plan_type}/{period}/{filename}` | 直读静态文件 |
| DELETE | `/file/{sid}/...` | 校验 sid == user.id,否则 403 |
#### 6.6.5 /api/v1/webview
| 方法 | 路径 | 鉴权 | 说明 |
| --- | -------------------------- | -------------- | ------------------------------------------- |
| GET | `/redirect?token=&target=` | 否(凭 URL token) | 校验 token → set HttpOnly cookie → 302 target |
安全要点:
- `target` 仅允许相对路径(`/xxx`)或白名单域 `m.muguaclub.cn` 及其子域
- cookie:`HttpOnly`、`Secure(scheme=https)`、`SameSite=Lax`、`MaxAge=JWT_TTL_SECONDS`
#### 6.6.6 /api/v1/survey(独立库 `myxhxc`,无鉴权)
| 方法 | 路径 | 说明 |
| ---- | ------------------------------------------ | ------------------- |
| GET | `/tasks` | 状态为 active 的所有 task |
| GET | `/responses?task_id=` | 某 task 的所有 group 回答 |
| POST | `/response` `{task_id, group_id, answers}` | upsert |
| PUT | `/response/{rid}` `{answers}` | 更新已有 |
| POST | `/task` | 占位,功能预留 |
#### 6.6.7 /api/v1/feedback
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------ | --------- | --------------------------------------------- |
| GET | `""` | ❌ 无 | 读所有 json 文件,按 unsolved/solved 倒序 |
| POST | `""` `{name?, description?, image?}` | 否 | 写一个 4 位 hex id 的 JSON,可附 data:image base64 图片 |
| GET | `/file/{name}` | 否 | 直读图片 |
| POST | `/resolve?id=&token=` | ADMIN_KEY | 标记为 solved |
> ⚠ 已知问题:`/feedback` GET 当前无鉴权,任何人能看所有反馈(**安全风险**)
#### 6.6.8 /api/v1/course_vote
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------ | --- | ------------------------------ |
| POST | `/course-vote` `{votes[3-6], rating?, comment?}` | 是 | 写入 `COURSE_VOTES_FILE` JSON 文件 |
| GET | `/course-votes` | 是 | 聚合(总票数 / 评论均分)+ 我的投票 |
> 用 JSON 文件存,无数据库,便于迁移自老 Flask。
#### 6.6.9 /api/v1/yijing
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------------- | --- | ---------------------------------------------------------------- |
| GET | `/catalog` | 否 | 八卦 + 六十四卦元数据 |
| GET | `/progress` | 是 | 5 个阶段(1..5)的学习进度 |
| POST | `/progress` `{stage[1-5], item_type, item_id, status?}` | 是 | upsert |
| POST | `/quiz` `{stage, item_type, item_id, correct, answer?}` | 是 | 记一次尝试,对应进度 score++;score ≥ 2 → mastered;写入 yijing_reviews 用于复习排期 |
### 6.7 后端非业务模块
| 模块 | 用途 |
| ------------------------------ | ----------------------- |
| `start.sh` / `stop.sh` | 启停脚本(开发环境) |
| `start_prd.sh` / `stop_prd.sh` | 生产环境启停(pid 文件管理) |
| `index.html` | 部署目录下的 health 静态页 |
| `requirements.txt` | 依赖清单 |
| `.env` | **曾被 git 跟踪,含密钥**(已知问题) |
| `uvicorn.log` | 运行日志 |
---
## 7. 数据模型
> 由 `auth_users / auth_sessions / classes / groups / class_groups / group_history / checkin_records_old / plan_checkins / yijing_trigrams / yijing_hexagrams / yijing_progress / yijing_attempts / yijing_reviews / survey_tasks / survey_responses` 等表名 / SQL 反推;具体 DDL 不在 `server/` 内。
### 7.1 主业务库(mys_info_v3)
| 表 | 主键 | 关键字段 | 用途 |
| --------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `auth_users` | `id` | `student_id, name, phone_encrypted(HMAC-SHA256), wechat_openid(唯一键), wechat_avatar, login_type, role, \`group\`` | 用户账号 + 资料 |
| `auth_sessions` | `id` | `user_id, token(唯一), expires_at` | 会话(90 天) |
| `classes` | `id` | `code, name` | 班级(期/班次) |
| `groups` | `id` | `group_code, sort` | 小组 |
| `class_groups` | (复合) | `class_id, group_id, user_id, role_in_group` | 班级 × 小组 × 学员 多对多 |
| `group_history` | (复合) | `user_id, class_id, old_group_id, changed_at` | 学员所在小组变迁 |
| `checkin_records_old` | (复合) `uk_checkin_user_date(user_id, class_date)` | `user_id, class_date, checked_at, status, note` | **打卡记录**(注:表名保留 `_old` 后缀,迁移按 user_id,详见 migration 003) |
| `plan_checkins` | (复合) `uk_plan_user_period(user_id, plan_type, period_key)` | `user_id, plan_type, period_key, status(已完成/未完成), checked_at, content, attachments(JSON), created_at, updated_at` + 索引 `idx_plan_period_user` | 计划记录 |
| `yijing_trigrams` | `id` | `name, symbol, nature, de, family, direction, xiantian, houtian, binary_code, class_xiang, summary` | 八卦(教材化元数据) |
| `yijing_hexagrams` | `id` | `name, full_name, symbol, upper_trigram, lower_trigram, binary_code, judgment, gua_explanation, bagong, gong_wuxing` | 六十四卦 |
| `yijing_progress` | (复合 stage, item_type, item_id) | `user_id, stage, item_type, item_id, status(learning/mastered), score, attempts, correct` | 学习进度 |
| `yijing_attempts` | `id` | `user_id, stage, item_type, item_id, correct, answer` | 尝试明细 |
| `yijing_reviews` | (复合) | `user_id, item_type, item_id, due_at` | 复习到期时间 |
### 7.2 问卷库(myxhxc)
| 表 | 关键字段 |
| ------------------ | ---------------------------------- |
| `survey_tasks` | `task_no, status(active), ...` |
| `survey_responses` | `task_id, group_id, answers(JSON)` |
### 7.3 文件存储
| 路径 | 用途 |
| ---------------------------------------------------------------------- | -------------- |
| `/home/static/avatars/{uid}/avatar{ext}` | 个人头像(nginx 直读) |
| `/home/static/plan/attachments/{user_id}/{plan_type}/{period_key}/...` | 计划附件 |
| `/home/introduction/feedback/*.json` + `images/*.png` | 反馈 JSON + 图片 |
| `/home/introduction/test/course_votes.json` | 课程投票 JSON |
### 7.4 迁移状态(migrations/)
- **001**:给 `auth_users` 加 `wechat_openid` 唯一键(绑微信前提)
- **002**:给 `plan_checkins` 加 `(plan_type, period_key, student_id)` 索引(性能,已被 004 替换)
- **003**:`checkin_records_old` 改按 `user_id` 关联,加唯一键 `(user_id, class_date)`
- **004**:`plan_checkins` 改按 `user_id` 关联,加 `(user_id, plan_type, period_key)` 唯一键 + 替换索引;附件相对路径从 student_id 改写为 user_id
---
## 8. 部署与配置
### 8.1 启动命令(开发)
```bash
pip install -r requirements.txt
# 配置 server/.env:WECHAT_APPID / WECHAT_SECRET / MYSQL_* / PHONE_HMAC_KEY / ADMIN_KEY / H5_BASE_URL
python -m uvicorn server.main:app --reload --port 8000
```
### 8.2 启动命令(生产)
```bash
# 启动
bash server/start.sh
# 停止
bash server/stop.sh
```
### 8.3 nginx 转发示例(推断)
```
# /api/v1/* → :8000 (uvicorn)
# /clock/static/* → /home/static/ (nginx 直读)
# /clock/api/v1/webview/* → :8000 (cookie 桥接)
# / → upstream muguaclub.cn / 8001 (H5 主站)
```
### 8.4 域名与端口
| 角色 | 域名 | 后端端口 |
| --- | ------------------- | -------------------------- |
| 正式 | `m.muguaclub.cn` | 8000 (FastAPI) + 8001 (H5) |
| 测试 | `test.muguaclub.cn` | 同上 |
---
## 9. 安全与合规
### 9.1 鉴权
- 单 token 体系:所有受保护接口统一用 `get_current_user`(`Authorization` 或 cookie)
- 退出登录:删 session + 解绑 openid(下次需重新授权手机号)
- WebView 桥接:URL 上短期 token → cookie,再 302 跳转同站域(防开放重定向)
### 9.2 传输安全
- 头像直链强制 https(`secure=request.url.scheme == "https"`)
- Cookie `HttpOnly + SameSite=Lax`
- OpenAPI 全 HTTPS(微信 API 仅 https)
### 9.3 数据安全
- 手机号 HMAC-SHA256 存 `phone_encrypted`,原号不出库
- 反馈、计划内容、明文入库(小程序端有 maxlength 1000 / 500 限制)
### 9.4 隐私合规
- 「我的」页强制要求勾选隐私政策 + 用户协议才能调 `getPhoneNumber`
- 隐私协议 8 节;用户协议 8 节;最后更新 2026 年 9 月(占位)
- 客服邮箱 `[此处填写客服邮箱]` 占位需补充
---
## 10. 非功能性需求
### 10.1 性能
| 维度 | 要求 |
| ------- | ----------------------------------- |
| 首屏冷启动 | 幂等请求(GET/DELETE)网络错误自动 800ms 重试 1 次 |
| 单实例 QPS | 与连接池 `maxconnections=10` 匹配;超出排队 |
| 单次连接 | connect_timeout=5s,read_timeout=5s |
| 列表分页 | 打卡默认 10,最大 50;计划默认 10,最大 50 |
| 客户端压缩 | 计划图片 >1MB 自动 80→60→45 三级降质压缩 |
| 上传上限 | 单文件 10MB(计划)/ 4MB(头像) |
### 10.2 可用性
- 单会话 90 天有效,过期即失效(前端需重新登录)
- 计划历史周期只读(can_submit=false)
- 学员只能查自己所在小组
- 切换 tab / 周选择器 / 周期选择器都有 fallback(数据为空时不刷)
### 10.3 可观测性(缺)
代码中未发现业务埋点 / 性能监控 / 错误聚合;`uvicorn.log` 仅记录关键 warn/error(如 `微信登录 code 换取 openid 失败`)。
### 10.4 兼容性
- 小程序基础库 `>=3.0.0`(`wx.getAccountInfoSync`)
- Python ≥3.9(FastAPI 最低版本)
---
## 11. 错误约定
- HTTP 状态码:
- `200` 业务正常
- `400` 参数错误(含业务校验:「仅可填写当前周期」「已过打卡时间」等)
- `401` 缺 token / token 无效或过期
- `403` 无权限(学员查他组 / 删他人附件)
- `404` 资源不存在
- `500` 未捕获的运行时错误(FastAPI 默认)
- 错误体:
- FastAPI 默认:`{"detail": "..."}`
- 业务自定义:`{"error": "..."}` 或 `{"success": false, "error": "..."}` 或 `{"need_phone": true, "error": "..."}`
- 前端解析顺序(`pages/checkin/checkin.js`):`err.detail → err.error → err.message`
---
> 文档结束。如有功能/接口改动,请同步更新本文件章节 5/6 并在文末「更新日志」追加条目。

View File

@@ -1,18 +1,134 @@
package com.mingyuan.android_client.feature.auth package com.mingyuan.android_client.feature.auth
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
/** Navigation route for this feature, referenced by the app-level NavHost. */ /** Navigation route for this feature, referenced by the app-level NavHost. */
const val AUTH_ROUTE = "auth" const val AUTH_ROUTE = "auth"
private val PHONE_PATTERN = Regex("^1\\d{10}$")
@Composable @Composable
fun AuthScreen(modifier: Modifier = Modifier) { fun AuthScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onLoggedIn: () -> Unit,
Text(text = "Auth") modifier: Modifier = Modifier,
viewModel: AuthViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var privacyAgreed by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loginSuccess.collect { onLoggedIn() }
}
val phoneValid = PHONE_PATTERN.matches(phone)
val canSendCode = phoneValid && uiState.countdownSeconds == 0 && !uiState.isSendingCode
val canSubmit = phoneValid && code.isNotBlank() && privacyAgreed && !uiState.isSubmitting
Column(
modifier = modifier
.fillMaxSize()
.background(PageBackground)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(48.dp))
Text("手机号登录", color = PrimaryText, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("手机号仅用于账号绑定,不会公开展示", color = FaintText, fontSize = 13.sp)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
MingyuanButton(
text = when {
uiState.countdownSeconds > 0 -> "${uiState.countdownSeconds}s 后重发"
uiState.isSendingCode -> "发送中"
else -> "获取验证码"
},
onClick = { viewModel.requestSmsCode(phone) },
enabled = canSendCode,
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = privacyAgreed,
onCheckedChange = { privacyAgreed = it },
)
Text(
text = "我已阅读并同意《用户协议》和《隐私政策》",
color = FaintText,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(8.dp))
uiState.errorMessage?.let { message ->
Text(message, color = Danger, fontSize = 13.sp)
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(16.dp))
MingyuanButton(
text = if (uiState.isSubmitting) "登录中..." else "登录",
onClick = { viewModel.login(phone, code) },
enabled = canSubmit,
fullWidth = true,
)
} }
} }

View File

@@ -1,8 +1,74 @@
package com.mingyuan.android_client.feature.auth package com.mingyuan.android_client.feature.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.LoginResult
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AuthUiState(
val isSendingCode: Boolean = false,
val countdownSeconds: Int = 0,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor() : ViewModel() class AuthViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _loginSuccess = MutableSharedFlow<Unit>()
val loginSuccess: SharedFlow<Unit> = _loginSuccess
private var countdownJob: Job? = null
fun requestSmsCode(phone: String) {
if (_uiState.value.isSendingCode || _uiState.value.countdownSeconds > 0) return
viewModelScope.launch {
_uiState.update { it.copy(isSendingCode = true, errorMessage = null) }
when (val result = sessionRepository.requestSmsCode(phone)) {
is SmsCodeResult.Success -> startCountdown()
is SmsCodeResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSendingCode = false) }
}
}
fun login(phone: String, code: String) {
if (_uiState.value.isSubmitting) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, errorMessage = null) }
when (val result = sessionRepository.login(phone, code)) {
is LoginResult.Success -> _loginSuccess.emit(Unit)
is LoginResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSubmitting = false) }
}
}
private fun startCountdown() {
countdownJob?.cancel()
countdownJob = viewModelScope.launch {
for (seconds in 60 downTo 1) {
_uiState.update { it.copy(countdownSeconds = seconds) }
delay(1000)
}
_uiState.update { it.copy(countdownSeconds = 0) }
}
}
}

View File

@@ -58,7 +58,7 @@ const val CHECKIN_ROUTE = "checkin"
@Composable @Composable
fun CheckinScreen( fun CheckinScreen(
onNavigateToProfile: () -> Unit, onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CheckinViewModel = hiltViewModel(), viewModel: CheckinViewModel = hiltViewModel(),
) { ) {
@@ -68,7 +68,7 @@ fun CheckinScreen(
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
if (uiState is CheckinUiState.LoggedOut) { if (uiState is CheckinUiState.LoggedOut) {
onNavigateToProfile() onNavigateToAuth()
} }
} }

View File

@@ -56,7 +56,7 @@ const val HOME_ROUTE = "home"
fun HomeScreen( fun HomeScreen(
onNavigateToCheckin: () -> Unit, onNavigateToCheckin: () -> Unit,
onNavigateToPlan: () -> Unit, onNavigateToPlan: () -> Unit,
onNavigateToProfile: () -> Unit, onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
) { ) {
@@ -97,7 +97,7 @@ fun HomeScreen(
TextButton( TextButton(
onClick = { onClick = {
showLoginDialog = false showLoginDialog = false
onNavigateToProfile() onNavigateToAuth()
}, },
) { ) {
Text("去登录") Text("去登录")

View File

@@ -68,7 +68,7 @@ const val PLAN_ROUTE = "plan"
@Composable @Composable
fun PlanScreen( fun PlanScreen(
onNavigateToPlanSubmit: (PlanType, String) -> Unit, onNavigateToPlanSubmit: (PlanType, String) -> Unit,
onNavigateToProfile: () -> Unit, onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlanViewModel = hiltViewModel(), viewModel: PlanViewModel = hiltViewModel(),
) { ) {
@@ -77,7 +77,7 @@ fun PlanScreen(
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
if (uiState is PlanUiState.LoggedOut) { if (uiState is PlanUiState.LoggedOut) {
onNavigateToProfile() onNavigateToAuth()
} }
} }

View File

@@ -55,6 +55,7 @@ const val PROFILE_ROUTE = "profile"
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
onNavigateToAuth: () -> Unit,
onLoggedOut: () -> Unit, onLoggedOut: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ProfileViewModel = hiltViewModel(), viewModel: ProfileViewModel = hiltViewModel(),
@@ -68,7 +69,7 @@ fun ProfileScreen(
when (val state = uiState) { when (val state = uiState) {
is ProfileUiState.Loading -> ProfileSkeleton(modifier) is ProfileUiState.Loading -> ProfileSkeleton(modifier)
is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = viewModel::login, modifier = modifier) is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = onNavigateToAuth, modifier = modifier)
is ProfileUiState.Error -> ErrorState( is ProfileUiState.Error -> ErrorState(
message = state.message, message = state.message,
onRetry = {}, onRetry = {},

View File

@@ -52,12 +52,6 @@ class ProfileViewModel @Inject constructor(
private val _logoutEvents = MutableSharedFlow<Unit>() private val _logoutEvents = MutableSharedFlow<Unit>()
val logoutEvents: SharedFlow<Unit> = _logoutEvents val logoutEvents: SharedFlow<Unit> = _logoutEvents
fun login() {
viewModelScope.launch {
sessionRepository.login()
}
}
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
sessionRepository.logout() sessionRepository.logout()

View File

@@ -0,0 +1,19 @@
# 服务器凭据模板 —— 复制为 secrets.properties 并填入真实值
# secrets.properties 已被 .gitignore 忽略(绝不提交);本 example 文件可安全提交,供 clone 后照着填。
#
# ---- SSH(生产机)----
SSH_HOST=
SSH_PORT=22
SSH_USER=
SSH_PASSWORD=
# ---- MySQL 生产库 ----
MYSQL_PROD_HOST=
MYSQL_PROD_PORT=3306
MYSQL_PROD_USER=
MYSQL_PROD_PASSWORD=
MYSQL_PROD_DB=mys_info_v3
# ---- 后端 API ----
API_BASE_URL=https://m.muguaclub.cn/clock
API_TOKEN=