Flutter 睡眠 App 登录功能梳理(手机号验证码 + Token 会话)
本文基于 Flutter 业务工程通读整理,说明手机号 + 短信验证码登录的完整链路:涉及哪些页面、字段、本地存储、HTTP 鉴权,以及登录态在全局如何被消费。文中 API 主机与包名均用占位符,便于公开发布。
1. 登录方式概览
| 项目 | 实现 |
|---|---|
| 认证方式 | 手机号 11 位 + 6 位短信验证码 |
| 协议 | 登录页勾选《用户协议》《隐私政策》(URL 来自通用配置接口) |
| 会话凭证 | 服务端返回 token,本地持久化 |
| 自动登录 | Splash 判断 token 非空 → 进入主应用,否则进登录页 |
| 退出 | 仅本地清空 token/用户对象,无服务端 logout 接口调用 |
2. 端到端流程
sequenceDiagram
participant U as 用户
participant Splash as Splash
participant Login as LoginView
participant API as LoginServiceApi
participant Store as UserInfoUtils
participant Main as MainApp
U->>Splash: 启动 App
Splash->>Store: getToken()
alt token 非空
Splash->>Main: pushReplacement
else token 为空
Splash->>Login: pushReplacement
end
U->>Login: 输入手机号
Login->>API: GET common/sendSecurityCode?phone=
U->>Login: 验证码 + 勾选协议
Login->>API: GET app/login?phone=&securityCode=
API-->>Login: code=200, data=UserInfoBeanEntity
Login->>Store: saveUserInfo / saveToken / saveUserId / saveHeadImage
Login->>Main: pushReplacement
Note over Main: 后续请求经 HttpParamsInterceptor 带 token
与 Splash 隐私弹窗的区别
KEY_PRIVACY:首次启动隐私指引(main.dart),同意后才进入倒计时跳转。- 登录页
isCheck:用户协议勾选,与隐私弹窗是两套合规逻辑。
3. 核心文件
| 文件 | 职责 |
|---|---|
lib/ui/LoginView.dart |
登录 UI、60s 验证码倒计时、登录/发码、拉通用配置 |
lib/ui/service/LoginServiceApi.dart |
发码、登录、getComConfig 等 |
lib/utils/UserInfoUtils.dart |
内存缓存 + SharedPreferences |
lib/utils/LocalStorage.dart |
SP 封装 |
lib/config/BaseConstants.dart |
存储 Key |
lib/net/interceptor/HttpInterceptor.dart |
注入 access_token、userId、device |
lib/main.dart |
Splash、隐私、按 token 分流 |
lib/MainApp.dart |
监听 LogOut / ReenterEvent → 回 Splash |
lib/ui/my/UserInfoView.dart |
资料编辑、退出/注销 |
4. 服务端接口
常量见 lib/config/ApiConstants.dart,调用见 LoginServiceApi:
| 接口 | 路径常量 | HTTP | 参数 |
|---|---|---|---|
| 发送验证码 | URL_SEND_SECURITY_CODE → common/sendSecurityCode |
GET | phone |
| 登录 | URL_LOGIN → app/login |
GET | phone, securityCode |
| 通用链接 | URL_GET_RELATEDLINKS → common/getRelatedLinks |
GET | 无 |
基址由 BaseApi.getUrl 决定(ApiConstants.isDeBug 切换测试/正式环境,如 https://api.example.com/)。
登录成功响应(BaseResultBeanEntity,code == 200):
{
"code": 200,
"message": "成功",
"data": {
"token": "YOUR_ACCESS_TOKEN",
"userInfo": {
"userId": "user-id-string",
"phone": 13800138000,
"nickName": "昵称",
"headImage": "https://cdn.example.com/avatar.png",
"birthDay": "1990-01-01",
"sex": 1,
"age": 30,
"registTime": "...",
"updateTime": "..."
}
}
}
5. 数据模型与字段
5.1 UserInfoBeanEntity
定义:lib/ui/bean/UserInfoBeanEntity.dart,JSON 映射:UserInfoBeanEntity.g.dart。
| 字段 | 类型 | 说明 |
|---|---|---|
token |
String |
访问令牌 |
userInfo |
UserInfoBeanUserInfo? |
用户资料嵌套对象 |
5.2 UserInfoBeanUserInfo
| 字段 | JSON 键 | 类型 | 典型用途 |
|---|---|---|---|
userId |
userId |
String? |
业务主键、H5 query、日志 |
phone |
phone |
int? |
登录页回填、我的页脱敏展示 |
nickName |
nickName |
String? |
昵称 |
headImage |
headImage |
String? |
头像 URL |
birthDay |
birthDay |
String? |
生日 |
sex |
sex |
int? |
性别 |
age |
age |
int? |
年龄 |
registTime |
registTime |
String? |
注册时间 |
updateTime |
updateTime |
String? |
更新时间 |
5.3 ComConfigBeanEntity(非登录响应,登录前拉取)
| 字段 | 本地 Key | 用途 |
|---|---|---|
instructionVideoLink |
KEY_VIDEOLINK |
说明视频 |
webStoreLink |
KEY_WEBSTORELINK |
商城 H5 |
termsOfUseLink |
KEY_TERMSOFUSELINK |
登录页用户协议 |
privacyPolicyLink |
KEY_PRIVACYPOLICYLINK |
登录页隐私政策 |
6. 本地存储
通过 UserInfoUtils 写入 LocalStorage(SharedPreferences):
| Key 常量 | 存储内容 | 登录成功时 |
|---|---|---|
KEY_USER_INFO |
完整 UserInfoBeanEntity JSON |
✅ saveUserInfo |
KEY_TOKEN |
token 字符串 | ✅ saveToken |
KEY_USERID |
userId 字符串 | ✅ saveUserId |
KEY_HEADIMAGE |
头像 URL | ✅ saveHeadImage |
KEY_DEVICEINFO |
机型/系统/版本拼接 | ✅ 进登录页 getDeviceInfo |
KEY_PRIVACY |
是否同意启动隐私弹窗 | Splash,非登录勾选 |
内存缓存:mToken、mUserId、mHeadImage、mDevice、userInfoBeanEntity,减少重复读 SP。
getToken() 回退顺序:
- 内存
mToken KEY_TOKENgetUserInfo()!.token
7. 登录成功后的写入代码
LoginView.login(节选):
final str = await LoginServiceApi().login(phone, code);
final baseResult = BaseResultBeanEntity.fromJson(str);
if (baseResult.code == 200) {
var userInfoBeanEntity =
jsonConvert.convert<UserInfoBeanEntity>(baseResult.data);
UserInfoUtils.saveUserInfo(userInfoBeanEntity);
UserInfoUtils.saveToken(userInfoBeanEntity.token.toString());
UserInfoUtils.saveHeadImage(userInfoBeanEntity.userInfo!.headImage!);
UserInfoUtils.saveUserId(userInfoBeanEntity.userInfo!.userId.toString());
RouterUtils.pushReplacement(context, const MainApp());
}
8. HTTP 鉴权(登录之后)
HttpParamsInterceptor 在每个 BaseApi 请求 onRequest 中设置 Header:
| Header | 来源 | 为空时 |
|---|---|---|
access_token |
getToken() |
"11" |
userId |
getUserId() |
"11" |
device |
getDevice() + userId 拼接 |
"11" |
source |
固定 "1" |
— |
注意:token 清空后仍可能带占位 "11",依赖服务端拒绝非法会话。ErrorInterceptor 未统一处理 401 跳转登录。
9. 启动分流
main.dart → _SplashAppViewState._jumpRoutePage:
token = UserInfoUtils.getToken();
if (token.isNotEmpty) {
RouterUtils.pushReplacement(context, const MainApp());
} else {
RouterUtils.pushReplacement(context, const LoginView());
}
仅判断 token,不单独校验 userId。
10. 退出与重新登录
flowchart LR
A[UserInfoView 退出/注销] --> B[saveToken 空字符串]
A --> C[saveUserInfo 空对象]
A --> D[fireEvent LogOut]
D --> E[MainApp pushReplacement Splash]
E --> F{token 空?}
F -->|是| G[LoginView]
| 操作 | 位置 | 服务端 |
|---|---|---|
| 退出登录 | showExitLoginDialog |
❌ |
| 注销 | showLogoutDialog |
❌(文案不同,逻辑同退出) |
LogOut |
MainApp 监听 |
回 Splash |
ReenterEvent |
MainApp 监听 |
回 Splash |
退出时 UserInfoUtils.clean() 被注释,蓝牙 MAC、范式等 SP 项可能仍保留。会调用 BluetoothUtils.disconnectCurBle()。
11. 登录后资料编辑(UserServiceApi)
入口:NewMy → UserInfoView。均需已登录 Header。
| 功能 | 请求 | 本地 |
|---|---|---|
| 昵称 | PUT app/editUser?nickName= |
更新 userInfo → saveUserInfo |
| 性别 | PUT app/editUser?sex= |
同上 |
| 生日 | PUT app/editUser?birthDay= |
同上 |
| 头像 | OSS 上传 → PUT 带头像 URL |
saveHeadImage + saveUserInfo |
成功后 EventBus 发 UpdataUserEvent,MyUserView 刷新我的页头部。
12. userId / token 在工程中的使用
12.1 强依赖 userId(拼 URL 或业务 ID)
| 模块 | 文件 | 用法 |
|---|---|---|
| 睡眠日记 H5 | SleepNote.dart |
query userId |
| 问卷/游戏 | QuestionNaireView.dart, GameIntroduceView.dart |
getUserId() |
| 设备帮助 | DeviceHelpListView.dart |
常见问题 H5 |
| 枕垫 API | pillow_serviceApi.dart |
用户标识 |
| 日志 | FileWriteLog.dart, main.dart |
崩溃/操作日志 |
| 闹钟 | AlarmSettingOffOnPage.dart |
多处 |
12.2 凡走 BaseApi 的模块
HomeServiceApi、ReportsServiceApi、DevicesServiceApi、UserServiceApi、SnoreServiceApi 等——统一依赖拦截器中的 token + userId + device。
12.3 展示层
| 位置 | 数据 |
|---|---|
MyUserView |
脱敏手机号、头像、性别 |
LoginView init |
缓存用户预填手机号 |
13. LoginServiceApi 其它方法(登录态复用)
| 方法 | 接口 | 主要调用方 |
|---|---|---|
getComConfig |
common/getRelatedLinks |
LoginView, MainApp |
getUserRemind |
用户提醒 | HomeX8View |
getIndexBanner |
首页 Banner | HomeX8View |
getHomeModuleList |
首页模块 | Home |
getHospitalAddress |
医院列表 | HomeSleep |
14. 相关页面一览
| 页面 | 路径 | 与登录关系 |
|---|---|---|
| Splash | lib/main.dart |
token 分流 |
| LoginView | lib/ui/LoginView.dart |
唯一登录页 |
| MainApp | lib/MainApp.dart |
登录后主容器 |
| NavigationBottom | lib/NavigationBottom.dart |
三 Tab,需登录态 |
| UserInfoView | lib/ui/my/UserInfoView.dart |
资料、退出 |
| ComWebView | lib/ui/ComWebView.dart |
协议 H5 |
| 业务页 | 报告/首页/设备等 | 假定已登录 |
15. 重要程度与分类
| 等级 | 能力 |
|---|---|
| P0 | 发码、登录、存 token/userId、Splash 分流、HttpParamsInterceptor |
| P1 | 通用配置(协议链接)、UserInfoView 资料编辑 |
| P2 | 各业务 H5 带 userId;token 空时 Header 占位行为 |
| 分类 | 内容 |
|---|---|
| 服务器 | 发码、登录、editUser、getRelatedLinks;登录后全部 API |
| 本地 | SP 持久化、隐私 KEY、退出清 token |
| 硬件 | 退出时断 BLE,与会话独立 |
16. 实现注意点
- 发码与登录均为 GET,参数在 query。
- 自动登录只看 token,不看 userId。
- 无服务端 logout;安全依赖 token 失效策略。
UserServiceApi.editUserHead等 PUT 路径在代码中与URL_EDITUSER_SEX共用,联调时需与后端路径一致。UserGameBeanEntity仅有 JSON 基础设施,不参与 App 登录流程;小游戏 H5 直接用getUserInfo()?.userInfo?.userId。
17. 快速定位
| 需求 | 位置 |
|---|---|
| 登录 UI 校验 | LoginView.dart 约 246–570 行 |
| 存 token | LoginView.login + UserInfoUtils |
| 是否已登录 | main.dart _jumpRoutePage |
| 请求头 token | HttpInterceptor.dart |
| 退出 | UserInfoView 739–771 行;MainApp LogOut 监听 |
| 字段定义 | UserInfoBeanEntity.dart |
小结
登录链路是 验证码登录 → 持久化 token 与 userInfo → Splash 按 token 分流 → 全 App 经拦截器带 access_token。userId 除 Header 外还广泛用于 H5 与日志。退出仅清本地会话并回 Splash,不调用注销 API。理解 UserInfoUtils 与 HttpParamsInterceptor 是排查「登录后接口 401 / 用户错乱」的第一入口。