Files
ttstd_family_care/AGENTS.md
TongTongStudio 06cdc77172 feat(android): 支持沉浸式状态栏、地图定位与自定义签名
- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配
- 新增百度地图权限、AK 配置及定位权限说明
- 接入 MethodChannel 实现返回键退后台热启动优化
- 配置自定义签名并更新构建逻辑
- 完善 token 刷新与鉴权失效统一处理
- 新增地图页与截图页路由
- 修复返回键拦截导致的 PopScope 失效问题
2026-08-19 03:43:12 +08:00

386 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flutter 项目规范
> 严格遵循本文档定义的架构模式、状态管理标准和代码风格。未经授权不得引入新的第三方包或架构模式。优先组合而非继承,业务逻辑不进入 UI 组件。
---
## 1. 技术栈
- **语言:** DartSDK `^3.12.2`,严格空安全)
- **框架:** Flutter**锁定版本,禁止使用 `flutter upgrade` 自由升级**,详见 [§11 版本管理](#11-版本管理)
- **状态管理:** `flutter_riverpod`v2.x注解 + 代码生成)
- **路由:** `go_router`v14.x含鉴权重定向详见 [§9 路由鉴权](#9-路由鉴权)
- **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化)
- **数据模型:** `freezed` + `build_runner`
- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌,平台配置见 [§8 安全](#8-安全)+ `mmkv`(高性能键值缓存)
- **依赖注入:** Riverpod Providers不使用 GetIt
- **国际化:** `flutter_localizations`l10n / ARB 格式)
> ⚠️ **版本一致性约束**:不同开发者执行 `flutter upgrade` 的时间不同会导致 SDK 版本漂移引发构建差异。Flutter 与 Dart SDK 版本必须以 CI / `.fvmrc` 为准,禁止在本地随意升级。
---
## 2. 目录结构
采用 **特性优先架构Feature-First**,特性内部结合 **整洁架构Clean Architecture** 分层:
```text
lib/
├── main.dart # 应用程序入口ProviderScope + runApp
├── app/ # 全局应用配置
│ ├── app.dart # CupertinoApp.router 入口
│ ├── router/ # 路由定义GoRouter含 redirect 鉴权)
│ ├── theme/ # 主题、颜色、排版
│ └── constants/ # 全局常量、API 端点、ICE 配置
├── core/ # 跨特性共享代码
│ ├── network/ # dio 客户端、拦截器链、统一错误模型AppError
│ ├── proto/ # ⚠️ 仅放【跨特性共享】的 Protobuf 生成代码(见 §3 边界规则)
│ ├── storage/ # 本地存储辅助工具shared_preferences / secure_storage / mmkv 封装)
│ ├── utils/ # 辅助函数、扩展方法
│ └── widgets/ # 共享 UI 组件(触摸层)
├── features/ # 特性模块
│ ├── auth/ # 登录 / 令牌 / 绑定列表
│ │ ├── data/ # Repository 实现dio
│ │ ├── domain/ # 仓库接口、状态模型freezed
│ │ └── presentation/ # 控制器、登录对话框
│ └── connection/ # 信令 / WebRTC / 控制面板
│ ├── data/ # 信令客户端、WebRTC、编排器、解码器、录制器
│ ├── domain/ # 信令消息、会话状态freezed、【单一特性专用】协议模型
│ └── presentation/ # 控制器、设置页、控制页、鉴权对话框
├── l10n/ # 国际化(.arb 文件,命名/组织约定见 §10
└── assets/ # 静态资源(目录结构与命名见 §7 资源管理)
├── images/
└── fonts/
```
### ⚠️ core/proto 与特性层边界规则
目录结构中 `core/proto` 存放 Protobuf 生成代码,但特性层的 `domain` 又标注"协议模型除外",两者边界容易混淆。明确规则如下:
- **跨特性共享的 `.proto`**(被两个及以上 feature 使用,或属于全局控制指令协议)→ 放 `core/proto/`
- **单一特性专用的协议模型**(如仅 `connection` 使用的信令消息)→ 放 `features/xxx/domain/models/`,不进 `core`
- 判断标准:问一句"去掉这个 feature 后,该 proto 还有人用吗?"没人用就留在 feature 内,保持内聚。
---
## 3. 分层职责
### 表示层(`presentation/`
UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。
- 只能通过 `ref.watch``ref.listen` 消费状态。
- 禁止在 `onPressed``build()` 中直接调用 API 或编写业务逻辑。
- 一次性提示通过状态中的 `alert` 字段传递UI 展示后调用 `consumeAlert()` 消费。
### 领域层(`domain/`
纯 Dart 实体(`freezed`)和仓库接口定义。
- 零 Flutter/UI 依赖(**单一特性专用的协议模型除外**,跨特性共享的协议模型在 `core/proto`)。
- 状态类命名避免与 Flutter SDK 冲突(如用 `ConnectionSessionState` 而非 `ConnectionState`)。
### 数据层(`data/`
实现仓库接口,通过 dio 处理 API 请求,将 JSON DTO 映射为领域实体。
---
## 4. 编码规范
### 状态管理Riverpod
- 使用 `@riverpod` 注解 + `build_runner` 代码生成。
- **`keepAlive: true` 使用约束(⚠️ 重要)**
- 仅限**全局单例级状态**(如 `Auth``Signaling` 会话控制器)使用,用于避免页面切换时销毁。
- **特性页面级控制器禁止使用 `keepAlive: true`**,否则会造成状态残留与内存泄漏。
- `keepAlive: true` 意味着 Provider **永远不会自动 dispose**,必须配套明确的清理策略:
- 用户**登出 / 断连**时主动调用 `ref.invalidate(provider)``ref.read(provider.notifier).reset()`(置 `ref.state = null` / 初始值防止旧状态token、连接会话残留。
- 全局单例的清理动作集中在登出流程(`auth` 控制器或 `app` 层的统一退出入口)中统一触发。
- 异步操作优先使用 `AsyncNotifierProvider`,配合 `AsyncValue`Loading / Data / Error
- 业务回调(信令/WebRTC 事件)统一在控制器内映射到状态,不在 UI 层直接持有控制器实例。
### 数据建模freezed
所有数据类/实体必须用 `freezed` 实现不可变,并配置 JSON 序列化:
```dart
@freezed
class SignalMessage with _$SignalMessage {
const SignalMessage._();
const factory SignalMessage({
String? type,
String? payload,
}) = _SignalMessage;
factory SignalMessage.fromJson(Map<String, dynamic> json) =>
_$SignalMessageFromJson(json);
// ⚠️ 安全toString() 直接 jsonEncode 会泄露 token / password 等敏感字段。
// 若模型不含敏感字段可保留;否则改为手动排除敏感字段,或干脆不覆盖 toString()。
@override
String toString() {
// 示例:排除敏感字段
final safe = toJson()..remove('token');
return 'SignalMessage(${safe.toString()})';
}
}
```
> ⚠️ **敏感信息防护**`jsonEncode(toJson())` 形式的 `toString()` 会把 token、password 等明文写入日志 / 崩溃上报。规范约定:
> 1. 含敏感字段的模型 **不得** 使用 `jsonEncode(toJson())` 形式的 `toString()`
> 2. 改为手动排除敏感字段后输出,或直接不覆盖 `toString()`(依赖调试器查看字段);
> 3. 若确需调试输出,标注 `@visibleForTesting` 并约定生产环境不调用。
### UI 组件
- 尽可能使用 `const` 构造函数,避免不必要的重建。
- 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法。
- 响应式适配使用 `LayoutBuilder` / `MediaQuery` 或项目统一的屏幕适配工具。
---
## 5. 命名约定
| 类别 | 规范 | 示例 |
|---|---|---|
| 文件/文件夹 | `snake_case.dart` | `connection_controller.dart` |
| 类/枚举 | `PascalCase` | `ConnectionSessionState` |
| 变量/方法 | `camelCase` | `sendResolutionChange()` |
| Provider | 以 `Provider` 结尾 | `authRepositoryProvider` |
| 私有成员 | `_` 前缀 | `_handleSignalMessage()` |
---
## 6. 代码生成
修改或新增带代码生成的模型/控制器后,运行:
```bash
# 一次性生成freezed / json_serializable / riverpod_generator
dart run build_runner build --delete-conflicting-outputs
# 监听模式
dart run build_runner watch --delete-conflicting-outputs
# 生成国际化l10n.yaml 已配置)
flutter gen-l10n
```
---
## 7. 资源管理
静态资源统一放在项目根 `assets/` 下,按类型分子目录:
```text
assets/
├── images/ # 图片资源
│ ├── common/ # 通用图标 / 占位图
│ └── feature_xxx/ # 按特性隔离的图片
└── fonts/ # 自定义字体
```
- **图片命名**`snake_case`,带用途/状态后缀,如 `ic_back.png``bg_login_dark@2x.png`;分辨率变体使用 `@2x` / `@3x` 后缀。
- **`pubspec.yaml` 引入**:在 `flutter.assets` 中显式声明目录(当前模板已注释示例,新增资源后需补齐),字体在 `flutter.fonts` 中声明 `family``asset`
- 超过 ~100KB 的图片优先走 CDN / 网络加载,避免包体积膨胀。
---
## 8. 安全
- **`flutter_secure_storage` 平台配置**
- **iOS**:数据落地于 **Keychain**(系统级加密,不随 app 卸载必然清除,需走钥匙串共享组或登录项)。
- **Android**:默认使用 **EncryptedSharedPreferences**(需 `minSdkVersion >= 23`;低于此需自定义 `AndroidOptions``encryptedSharedPreference` 开关)。
- 仅存放 `refreshToken` 等高危凭证,禁止存明文密码。
- **敏感字段防护**:见 §4 数据建模中 `toString()` 的约束token / password 不得进入日志与崩溃上报。
- **本地偏好**:非敏感配置(主题、语言)用 `shared_preferences`;高频读写缓存可用 `mmkv`
- **网络传输**:所有 API 走 HTTPS信令 WebSocket 使用 `wss://`
---
## 9. 路由鉴权
基于 `go_router` 实现统一的鉴权重定向:
- **受保护路由**:在 `app/router/` 中集中定义路由表,受保护路由(如 `connection/*`)标记为需鉴权。
- **未登录重定向**:通过 `GoRouter``redirect` 回调检查登录态(读取 `auth` 控制器的 `AsyncValue` / 本地 token。未登录时 `redirect``/login`,并把当前 `location` 写入 `extra` 或 query 参数。
- **登录回跳**:登录成功后读取 `extra` 中的目标路由,调用 `context.go()` 回跳原页面;无目标时默认跳转首页。
- **登出清理联动**:登出时除 §4 的 Provider 清理外,路由需 `go('/login')` 清空导航栈。
---
## 10. 国际化l10n
采用 ARB 格式,由 `l10n.yaml` 驱动 `flutter gen-l10n` 生成 `AppLocalizations`
- **文件命名 / 组织**:语言文件以 `intl_<locale>.arb` 命名(如 `intl_zh.arb``intl_en.arb``template-arb-file` 指定模板语言(当前为 `intl_zh.arb`)。
- **key 命名**`snake_case` 语义化,带上下文前缀避免冲突,如 `login_title``connection_video_off`
- **占位符**:使用 `{param}` 占位,对应生成的 getter 会带参数;多语言文案需保持占位符一致。
- **复数 / 选择**:使用 ARB 的 `plural` / `select` 语法(如 `@count` + `plural`),避免手动拼接。
- **维护约定**:新增文案须同时更新所有语言 ARB缺失的 key 以模板语言文案兜底。
---
## 11. 版本管理
- **锁定 Flutter / Dart 版本**:在 `README` 与 CI 中明确记录 `flutter --version` 对应的版本号。
- **推荐使用 FVM**:项目根放置 `.fvmrc`,团队成员统一 `fvm use`,禁止本地自由 `flutter upgrade`
- `pubspec.yaml``environment.sdk` 已锁定为 `^3.12.2`,依赖版本尽量用 caret 范围并定期 `flutter pub outdated` 审查。
---
## 12. 错误处理统一规范
> 本规范已在 `lib/core/network/` 落地,新增网络请求必须复用以下基础设施,禁止在 Repository 中自行创建 Dio 实例或手写 try/catch 转换异常。
- **统一错误模型 `AppError`**`api_error.dart`,含 `type`network / unauthorized / forbidden / server / business / unknown`code``statusCode``message``original`,以及 `isUnauthorized` / `isRetryable` 判定,与统一用户文案 `userMessage`)。数据/网络层统一向上抛 `AppError` 而非裸 `Exception` / `DioException`
- **兼容异常 `ApiException`**`api_exception.dart`):保留 `code` / `message` 字段以兼容既有调用方;新增代码建议直接使用 `AppError``DioClient` 对外统一抛出 `ApiException`
- **dio 拦截器链**(归属 `core/network/dio_client.dart`
1. `_AuthInterceptor`:注入 `Authorization: Bearer <token>`
2. `_ResponseInterceptor`:统一解析 `{ code, message, data }``code == 0` 提取 `data`,否则抛业务错误。
3. `_ErrorInterceptor`:将 `DioException` 转换为 `ApiException`**401 时自动用 `refreshToken` 刷新并重试一次**,刷新失败则向下传递 unauthorized 错误。
- **统一请求入口**`DioClient.get/post/put/delete` 已封装Repository 直接调用并捕获 `ApiException`,无需重复 try/catch。
- **错误分类与处理策略**
- `401``_ErrorInterceptor` 自动刷新 token 重试;刷新失败 → 控制器检测到 unauthorized 触发登出清理。
- `403` → 无权限,展示无权限提示。
- `4xx` 其他 / 业务错误 → 展示后台 `message`
- `5xx` / 网络不可达 → 提示重试UI 提供 retry 入口)。
- **统一文案映射**:所有控制器/UI 捕获异常后必须通过 `userMessageOf(e)``error_message.dart`)获取展示文案,**禁止直接 `toString()`**(防敏感信息泄露)。
- **UI 呈现**`AsyncValue` 的 Error 分支统一渲染——全局错误(如 401 登出)走 alert/Toast局部错误走内联错误 + retry 按钮,不强制全局错误页。
---
## 13. 测试规范
- **目录镜像**`test/` 下按 `test/features/<feature>/``test/core/` 镜像 `lib/` 结构。
- **Mock 规范**:使用 `ProviderContainer` + `overrides` 覆盖 Riverpod ProviderRepository / dio 用 `mockito` 或手写 fakeWebRTC / 信令用桩对象。
- **分层测试**:领域层纯函数单测;控制器用 `Container` override 后 `read(notifier)` 驱动并断言 `state`UI 用 `pumpWidget(ProviderScope(...))`
- **运行门槛**:提交前 `flutter test` 需通过(见 §14
---
## 14. Git / CI
- **Commit Message**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)`feat:` / `fix:` / `refactor:` / `docs:` / `test:` / `chore:`)。
- **PR 模板**含变更说明、影响范围、测试步骤、截图UI 变更)。
- **CI 流水线**(建议):
1. `flutter analyze` 无 issue。
2. `flutter test` 通过。
3. `dart run build_runner build --delete-conflicting-outputs` 校验生成代码最新。
4. 多端构建Android / iOS / Web产物校验。
- **协议同步**`.proto` / 信令 JSON 变更须同步 Android / iOS / Web 各端,并在 PR 中标注。
---
## 15. 完成标准
- 新文件遵循特性优先结构app / core / features / l10n / assets
- 代码为空安全、完全类型化,并进行 `const` 优化。
- 对应创建数据层、领域层和表示层组件。
- `keepAlive: true` 的控制器已配置登出/断连清理策略(见 §4
- 含敏感字段的模型已处理 `toString()` 泄露风险(见 §4、§8
- 提交前 `flutter analyze` 无 issue`flutter test` 通过。
- 协议(`.proto` / 信令 JSON变更需同步 Android / iOS / Web 各端。
---
## 16. C 端认证接口对接约定open 模块)
> 本节记录 Flutter`ttstd_family_care`)与后端 `youlai-boot-ttstd` 的 C 端认证接口契约,
> 对接 `open` 模块(`com.youlai.boot.open`),账号落地 `app_user` 表,与后台管理端 `sys_user` 物理分表隔离。
### 16.1 基础地址
`AppConstants.kBaseUrl` 为后端 open 模块根路径,**已包含 `/api/v1/open/` 前缀**
```dart
static const String kBaseUrl = 'http://<host>:<port>/api/v1/open/';
```
仓库层请求路径(如 `/login`)会拼接到该 BaseUrl 之后,即实际请求
`/api/v1/open/auth/login`。**不要**把 `/api/v1/open/``/auth` 再拼进 path。
> ⚠️ 前端**禁止**将 BaseUrl 改为 `/api/v1/sn/` 前缀。`/api/v1/sn/**` 由后端
> `MobileApiSignatureFilter` 强制校验设备签名四件套(`X-Device-SN` / `X-Nonce` /
> `X-Timestamp` / `X-Sign`),仅用于**设备被控端**通信C 端用户登录走 open 模块,
> 不经过该过滤器。若误用 `/sn/` 前缀,会返回 `A0801 设备标识不能为空` 等错误。
### 16.2 接口端点POST
所有请求均携带 `Authorization: Bearer <token>`(登录/发验证码等匿名接口可省略)。
| 功能 | 路径 | 请求参数 | 说明 |
|---|---|---|---|
| 账号密码登录 | `/login` | body `{username, password}` | `username` 为手机号或用户名 |
| 验证码登录 | `/login/mobile` | body `{mobile, code}` | 需先调用"发送登录验证码" |
| 发送登录验证码 | `/login/sms/code` | query `mobile` | 验证码存 Redis5 分钟有效 |
| 注册 | `/register/mobile` | body `{mobile, code, password}` | 注册成功即签发令牌 |
| 发送注册验证码 | `/register/sms/code` | query `mobile` | 手机号未注册时发送 |
| 重置密码 | `/reset-password` | body `{mobile, code, password}` | 需先调用"发送重置密码验证码" |
| 发送重置密码验证码 | `/reset-password/sms/code` | query `mobile` | 手机号必须已注册 |
| 刷新令牌 | `/refresh-token` | query `refreshToken` | 换取新访问令牌 |
| 退出登录 | `/logout` | query `accessToken`(可选) | 注销令牌 |
### 16.3 响应结构
- 成功:`{ "code": 0, "message": "...", "data": ... }`
- 失败:`code != 0``message` 为错误提示(由 `_ResponseInterceptor``ApiException`)。
登录/注册/刷新成功时 `data` 为后端 `AuthenticationToken`
```json
{ "tokenType": "Bearer", "accessToken": "xxx", "refreshToken": "yyy", "expiresIn": 7200 }
```
前端 `LoginResult.fromJson` 已兼容解析 `accessToken`(旧字段 `token` 作兜底)。
### 16.4 对应实现位置
- 数据层:`lib/features/auth/data/auth_repository_impl.dart`
- 令牌持久化:`lib/core/storage/token_storage.dart`
- 统一请求/拦截器:`lib/core/network/dio_client.dart`
- 领域模型:`lib/features/auth/domain/auth_models.dart``LoginResult``SmsScene`
### 16.5 场景映射
`SmsScene` 决定发送验证码的后端路径,仓库层按场景分流:
| `SmsScene` | 后端路径 | 触发页面 |
|---|---|---|
| `login` | `/login/sms/code` | 登录页(验证码登录) |
| `register` | `/register/sms/code` | 注册页 |
| `resetPassword` | `/reset-password/sms/code` | 忘记密码页 |
### 17 解耦适配
```dart
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
```
都变更为
```dart
import 'package:material_ui/material_ui.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
```
flutter_localizations也被拆分Material和Cupertino的本地化委托分别移进了对应的包。
迁移前:
```dart
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/material.dart';
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
GlobalCupertinoLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
```
迁移后:
```dart
import 'package:material_ui/material_ui.dart';
localizationsDelegates: GlobalMaterialLocalizations.delegates,
```