362 lines
19 KiB
Markdown
362 lines
19 KiB
Markdown
# 10. Embedded H5 容器与 JSBridge
|
||||
|
|
|
|||
|
|
## 为什么单独一篇
|
|||
|
|
|
|||
|
|
PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
|
|||
|
|
|
|||
|
|
**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
|
|||
|
|
|
|||
|
|
## 决策
|
|||
|
|
|
|||
|
|
| 项 | 决策 |
|
|||
|
|
|---|---|
|
|||
|
|
| WebView 库 | **[webview_flutter](https://pub.dev/packages/webview_flutter) `^4.14.1`** |
|
|||
|
|
| 归属包 | `core_webview`(依赖 `core_auth`、`native_scan`/`native_media`/`native_device`) |
|
|||
|
|
| 桥接通道 | 单一 JavaScript Channel `ContiBridge`,统一 `{id, method, params}` 协议 |
|
|||
|
|
| URL 来源 | 只接受 App Backend 换票后下发的 URL,**路由里不传裸 URL** |
|
|||
|
|
|
|||
|
|
### 为什么选 webview_flutter 而不是 flutter_inappwebview
|
|||
|
|
|
|||
|
|
| | webview_flutter | flutter_inappwebview |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 维护方 | **Flutter 官方(flutter.dev)** | 社区个人维护 |
|
|||
|
|
| 最新 stable | `4.14.1`,一个月前发布,持续更新 | `6.1.5`,**距今约 22 个月**,新特性都在 `6.2.0-beta` |
|
|||
|
|
| 能力覆盖 | 基础能力齐全,高级能力走平台特定 controller | 更丰富(拦截请求、Cookie 精细管理、下载) |
|
|||
|
|
| 我们实际需要的 | JS Channel、导航拦截、文件选择、Cookie 清理 | 同 |
|
|||
|
|
|
|||
|
|
`flutter_inappwebview` 能力更全,但**它的 stable 版本已经近两年没发布**,新功能和 bugfix 都压在 beta 上。对一个要跑核心交易链路、生命周期以年计的 App 来说,这是不能接受的维护风险——真出问题时我们只能自己 fork。
|
|||
|
|
|
|||
|
|
`webview_flutter` 的能力缺口(Android 的 `<input type="file">`)有官方解法,用平台特定 controller 即可:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
if (controller.platform is AndroidWebViewController) {
|
|||
|
|
await AndroidWebViewController.enableDebugging(env.enableLog);
|
|||
|
|
(controller.platform as AndroidWebViewController)
|
|||
|
|
.setOnShowFileSelector(_onShowFileSelector); // 交给 native_media 处理
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**如果后续发现 F6 页面用到了 `webview_flutter` 确实做不了的能力**(比如需要拦截并改写请求),再评估切换;届时因为所有 WebView 交互都收在 `core_webview` 一个包里,切换代价是可控的。这也是不让 `feature_*` 直接依赖 WebView 库的原因。
|
|||
|
|
|
|||
|
|
## H5 启动流程
|
|||
|
|
|
|||
|
|
对应 PRD §7.2:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户点击功能入口(feature_* 或工作台菜单)
|
|||
|
|
↓
|
|||
|
|
context.push('/webview?target=QUOTE_ORDER') ← 路由里只有 target,没有 URL
|
|||
|
|
↓
|
|||
|
|
core_webview: POST /api/v1/h5/launch { target }
|
|||
|
|
↓
|
|||
|
|
App Backend: 校验登录态 / 门店上下文 / 角色权限
|
|||
|
|
→ 经 F6 Integration Adapter 取票据
|
|||
|
|
↓
|
|||
|
|
返回 { url, ticket, expiresIn, title }
|
|||
|
|
↓
|
|||
|
|
core_webview: 域名白名单校验 → WebViewController.loadRequest(url)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// packages/core_webview/lib/src/h5_launch_repository.dart
|
|||
|
|
class H5LaunchInfo {
|
|||
|
|
final String url; // 已由后端拼好票据和上下文参数
|
|||
|
|
final String title;
|
|||
|
|
final Duration ttl; // 票据有效期,用于判断是否需要换票
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。** 客户端拼参数意味着 `userId`/`storeId`/`roleCode` 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 `QUOTE_ORDER`"。
|
|||
|
|
|
|||
|
|
客户端唯一负责传的是 `traceId`——请求 `/h5/launch` 时带的 `X-Trace-Id`(见 [05-networking.md](./05-networking.md)),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。
|
|||
|
|
|
|||
|
|
## 域名白名单
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// packages/core_webview/lib/src/url_guard.dart
|
|||
|
|
class UrlGuard {
|
|||
|
|
const UrlGuard(this._allowedHosts);
|
|||
|
|
final Set<String> _allowedHosts; // 来自 env/{flavor}.json,各环境不同
|
|||
|
|
|
|||
|
|
bool isAllowed(Uri uri) {
|
|||
|
|
if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD §7.6)
|
|||
|
|
final host = uri.host.toLowerCase();
|
|||
|
|
return _allowedHosts.any((allowed) =>
|
|||
|
|
host == allowed || host.endsWith('.$allowed'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
白名单在**三个位置**都要生效,缺一不可:
|
|||
|
|
|
|||
|
|
1. **首次加载前**:后端返回的 URL 校验一次(防后端配置错误)。
|
|||
|
|
2. **导航拦截**(`NavigationDelegate.onNavigationRequest`):H5 内部跳转到非白名单域名一律 `NavigationDecision.prevent`,并记一条埋点。
|
|||
|
|
3. **JSBridge 消息处理时**:每条消息都校验当前页面的 host(见下文「来源校验」)。
|
|||
|
|
|
|||
|
|
`endsWith('.$allowed')` 而不是 `contains`:`contains` 会让 `f6.example.com.evil.com` 通过校验,这是白名单实现里最经典的一个洞。
|
|||
|
|
|
|||
|
|
非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是**弹确认框后用系统浏览器打开**,避免用户点了没反应以为坏了。
|
|||
|
|
|
|||
|
|
## JSBridge 协议
|
|||
|
|
|
|||
|
|
### 通道与消息格式
|
|||
|
|
|
|||
|
|
只开**一个** JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
controller.addJavaScriptChannel(
|
|||
|
|
'ContiBridge',
|
|||
|
|
onMessageReceived: (message) => _bridge.handle(message.message),
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
H5 侧调用:
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
// 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」)
|
|||
|
|
const result = await window.ContiBridge.call('scan', { mode: 'barcode' });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**请求**(H5 → App):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**回包**(App → H5):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } }
|
|||
|
|
{ "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**主动事件**(App → H5,无 `id`):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "event": "storeChanged", "payload": { "storeId": 7 } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
约定:
|
|||
|
|
|
|||
|
|
- `id` 由 **H5 侧生成**并原样回传,App 不生成——这样 H5 侧的 Promise 映射表完全由它自己管理。
|
|||
|
|
- **所有回包都是异步的**,即使是同步能力(如 `getStoreContext`)。统一异步避免 H5 侧写两套调用方式。
|
|||
|
|
- `error.code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,`message` 只用于展示。
|
|||
|
|
- 未知 `method` 返回 `{ code: "UNSUPPORTED_METHOD" }` 而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。
|
|||
|
|
|
|||
|
|
### 能力清单(PRD §7.4)
|
|||
|
|
|
|||
|
|
| method | 说明 | 底层 | 备注 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `scan` | 打开扫码 | `native_scan` | `params.mode`: `barcode`/`vin`/`plate`(见 [07](./07-native-integration.md)) |
|
|||
|
|
| `camera` | 打开相机拍照 | `native_media` | 返回压缩后的本地路径 |
|
|||
|
|
| `pickImage` | 打开相册 | `native_media` | 支持多选,`params.maxCount` |
|
|||
|
|
| `uploadFile` | 上传图片/文件 | `core_network` | 带进度事件,见下文 |
|
|||
|
|
| `dial` | 调起拨号 | `native_device` | `ACTION_DIAL`/`tel:`,不直接拨出 |
|
|||
|
|
| `closePage` | 关闭当前 H5 页 | `core_router` | 等价于 `context.pop()` |
|
|||
|
|
| `goBack` | H5 内返回上一页 | WebView | 无历史时降级为 `closePage` |
|
|||
|
|
| `refresh` | 刷新页面 | WebView | |
|
|||
|
|
| `getAuthState` | 获取登录态 / 触发换票 | `core_auth` | **不返回 token 明文**,见安全约定 |
|
|||
|
|
| `getStoreContext` | 获取当前门店上下文 | `core_auth` | 返回 `storeId`/`storeCode`/`orgId`/`roleCode` |
|
|||
|
|
| `toast` / `dialog` / `loading` | 弹出提示 | `core_ui` | 用原生控件,保证与 App 其他页面视觉一致 |
|
|||
|
|
| `navigate` | 跳转 App 原生页面 | `core_router` | `params.route` 必须是**预定义的路由白名单**,不接受任意路径 |
|
|||
|
|
| `setTitle` | 设置导航栏标题 | `core_ui` | 与自动的 `title` 同步互补 |
|
|||
|
|
|
|||
|
|
`navigate` 的路由白名单和 `04-routing.md` 的「后端动态菜单 → 本地路由」用同一张 `menuRouteMap`——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。
|
|||
|
|
|
|||
|
|
### 来源校验(PRD §7.6)
|
|||
|
|
|
|||
|
|
**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
Future<void> handle(String raw) async {
|
|||
|
|
// 1. 当前页面必须在白名单内
|
|||
|
|
final current = await _controller.currentUrl();
|
|||
|
|
if (current == null || !_urlGuard.isAllowed(Uri.parse(current))) {
|
|||
|
|
_logger.w('[bridge] 拒绝来自非白名单页面的调用: $current');
|
|||
|
|
return; // 静默丢弃,不回包——不给探测者任何反馈
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩
|
|||
|
|
final Map<String, dynamic> req;
|
|||
|
|
try {
|
|||
|
|
req = jsonDecode(raw) as Map<String, dynamic>;
|
|||
|
|
} catch (_) {
|
|||
|
|
return _logger.w('[bridge] 无法解析的消息');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final id = req['id'] as String?;
|
|||
|
|
final method = req['method'] as String?;
|
|||
|
|
if (id == null || method == null) return;
|
|||
|
|
|
|||
|
|
final handler = _handlers[method];
|
|||
|
|
if (handler == null) {
|
|||
|
|
return _reply(id, error: const BridgeError('UNSUPPORTED_METHOD', '当前 App 版本不支持该能力'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
_reply(id, data: await handler(req['params'] as Map<String, dynamic>? ?? const {}));
|
|||
|
|
} on AppException catch (e) {
|
|||
|
|
_reply(id, error: BridgeError(e.bridgeCode, e.message));
|
|||
|
|
} catch (e, st) {
|
|||
|
|
_logger.e('[bridge] $method 未预期异常', error: e, stackTrace: st);
|
|||
|
|
_reply(id, error: const BridgeError('INTERNAL_ERROR', '操作失败,请重试'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> `currentUrl()` 返回的是**主 frame** 的 URL,所以这个校验能挡住"整页被导航到恶意站点后调 bridge",但挡不住"白名单页面内的恶意 iframe"。后者的正确解法是不让 F6 页面嵌不受信的 iframe(协议层面约定),以及在导航拦截里限制 iframe 加载的域名。这个限制要在与 F6 的接口评审里明确。
|
|||
|
|
|
|||
|
|
### 其他安全约定
|
|||
|
|
|
|||
|
|
- **`getAuthState` 不返回 token 明文**(PRD §7.6:"H5 页面不得直接保存 APP 明文 Token")。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。
|
|||
|
|
- **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。
|
|||
|
|
- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD §7.6),原始信息只进日志。
|
|||
|
|
|
|||
|
|
### JS 侧胶水
|
|||
|
|
|
|||
|
|
`window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。App 在 `onPageFinished` 时注入一段封装,把它包成 Promise:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
const _bridgeShim = r'''
|
|||
|
|
(function () {
|
|||
|
|
if (window.__contiBridgeReady) return;
|
|||
|
|
const pending = new Map();
|
|||
|
|
window.__contiBridgeCallback = function (resp) {
|
|||
|
|
const p = pending.get(resp.id);
|
|||
|
|
if (!p) return;
|
|||
|
|
pending.delete(resp.id);
|
|||
|
|
resp.ok ? p.resolve(resp.data) : p.reject(resp.error);
|
|||
|
|
};
|
|||
|
|
window.__contiBridgeEvent = function (evt) {
|
|||
|
|
window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload }));
|
|||
|
|
};
|
|||
|
|
const raw = window.ContiBridge;
|
|||
|
|
window.ContiBridge = {
|
|||
|
|
call: function (method, params) {
|
|||
|
|
const id = String(Date.now()) + Math.random().toString(36).slice(2);
|
|||
|
|
return new Promise(function (resolve, reject) {
|
|||
|
|
pending.set(id, { resolve: resolve, reject: reject });
|
|||
|
|
raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} }));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
window.__contiBridgeReady = true;
|
|||
|
|
})();
|
|||
|
|
''';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**注入时机是 `onPageFinished`,不是 `onPageStarted`**——`onPageStarted` 时 H5 的脚本可能还没执行完,重复注入或时序错乱。同时 `__contiBridgeReady` 做幂等保护,因为 SPA 内部路由变化可能触发多次回调。
|
|||
|
|
|
|||
|
|
H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 `window.__contiBridgeReady` 或监听一个 `conti:ready` 事件。**这条要写进给 F6 的接入文档**。
|
|||
|
|
|
|||
|
|
## 生命周期管理(PRD §7.5)
|
|||
|
|
|
|||
|
|
| 场景 | 处理 |
|
|||
|
|
|---|---|
|
|||
|
|
| **标题同步** | `onPageFinished` 后读 `document.title` 写入导航栏;`setTitle` bridge 调用优先级更高 |
|
|||
|
|
| **返回 vs 关闭** | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 `goBack()`,无历史则退出容器。关闭:直接退出容器,不管 H5 历史 |
|
|||
|
|
| **Android 物理返回键** | 与「返回」按钮同语义。**必须拦截**,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 |
|
|||
|
|
| **缓存策略** | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 `Cache-Control`)。**不做 App 侧的离线包**——首版没有这个必要,且离线包会引入版本管理复杂度 |
|
|||
|
|
| **票据过期** | 见下文 |
|
|||
|
|
| **白屏/超时** | 见下文 |
|
|||
|
|
| **上传中断** | 见下文 |
|
|||
|
|
| **门店切换 / 登出** | 见下文 |
|
|||
|
|
|
|||
|
|
### 票据过期后重新换票
|
|||
|
|
|
|||
|
|
票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径:
|
|||
|
|
|
|||
|
|
1. **H5 主动发现**:F6 页面收到票据失效的响应,调 `getAuthState` 请求刷新 → App 重新调 `/h5/launch` 拿新 URL → `loadRequest` 新 URL。
|
|||
|
|
2. **App 预判**:进入前台时若距离上次换票已超过 `ttl * 0.8`,主动换票并 reload。
|
|||
|
|
|
|||
|
|
**不要在票据过期时静默 reload**——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。
|
|||
|
|
|
|||
|
|
### 白屏、超时、网络失败兜底
|
|||
|
|
|
|||
|
|
WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
NavigationDelegate(
|
|||
|
|
onPageStarted: (_) => _startWatchdog(const Duration(seconds: 15)),
|
|||
|
|
onPageFinished: (_) { _cancelWatchdog(); _injectShim(); },
|
|||
|
|
onWebResourceError: (error) {
|
|||
|
|
// 只处理主文档的错误,子资源(某张图、某个 JS)失败不该整页报错
|
|||
|
|
if (!error.isForMainFrame!) return;
|
|||
|
|
_showErrorState(error);
|
|||
|
|
},
|
|||
|
|
onHttpError: (error) {
|
|||
|
|
if (error.response?.statusCode == 404) _showErrorState(...);
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **15 秒看门狗**:`onPageStarted` 后 15 秒还没 `onPageFinished` 就展示"加载超时,请重试"。WebView 在某些网络状况下既不成功也不报错,只有超时能兜住。
|
|||
|
|
- 错误页给「重试」和「返回」两个按钮,重试重新走完整的换票流程(票据可能已经过期了),不是简单 `reload()`。
|
|||
|
|
- 每次白屏/超时都**上报埋点**(`h5_failed`,带 `target`、错误码、耗时、`traceId`),见 [13-observability-analytics.md](./13-observability-analytics.md)。**这一类失败后端完全看不到**——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。
|
|||
|
|
|
|||
|
|
### 上传中断与重新提交
|
|||
|
|
|
|||
|
|
`uploadFile` 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定:
|
|||
|
|
|
|||
|
|
- 上传期间**拦截返回和关闭**,弹确认框「上传未完成,确定要离开吗?」。
|
|||
|
|
- 上传进度通过主动事件推给 H5(`{ event: "uploadProgress", payload: { taskId, sent, total } }`),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。
|
|||
|
|
- 上传失败的回包里带 `taskId`,H5 可以用同一个 `taskId` 重试,避免重复上传已成功的部分。
|
|||
|
|
- 具体上传实现(压缩、超时、单张重传)复用 [05-networking.md](./05-networking.md) 的 `ApiClient.upload`,`core_webview` 不自己写一套。
|
|||
|
|
|
|||
|
|
### 门店切换与登出时的会话失效
|
|||
|
|
|
|||
|
|
PRD §7.5 的默认策略是硬要求:
|
|||
|
|
|
|||
|
|
- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。**
|
|||
|
|
- **用户退出登录后,所有 H5 会话必须同步失效。**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// packages/core_webview/lib/src/webview_session.dart
|
|||
|
|
class WebViewSession {
|
|||
|
|
/// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md)
|
|||
|
|
Future<void> invalidateAll({required bool clearCookies}) async {
|
|||
|
|
for (final controller in _openControllers) {
|
|||
|
|
await controller.loadRequest(Uri.parse('about:blank')); // 先停掉页面,防止在途请求继续
|
|||
|
|
}
|
|||
|
|
if (clearCookies) {
|
|||
|
|
await WebViewCookieManager().clearCookies();
|
|||
|
|
await _controller.clearLocalStorage();
|
|||
|
|
await _controller.clearCache();
|
|||
|
|
}
|
|||
|
|
_openControllers.clear();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
区别:
|
|||
|
|
|
|||
|
|
- **门店切换**:关闭已打开的 H5 页并提示"门店已切换,请重新进入",**不清 Cookie**(用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。
|
|||
|
|
- **登出**:关闭所有 H5 页 + **清 Cookie / LocalStorage / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——同一台门店共用设备上这是真实会发生的。
|
|||
|
|
|
|||
|
|
清理动作**必须等待完成**再让新用户登录,不能 fire-and-forget。
|
|||
|
|
|
|||
|
|
## 与 F6 的接口对齐清单
|
|||
|
|
|
|||
|
|
以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发:
|
|||
|
|
|
|||
|
|
1. `ContiBridge` 的 12 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。
|
|||
|
|
2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。
|
|||
|
|
3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。
|
|||
|
|
4. F6 静态资源的 `Cache-Control` 策略。
|
|||
|
|
5. `error.code` 枚举表(App 侧定义,F6 侧按 code 分支)。
|
|||
|
|
6. H5 内部跳转是否会离开白名单域名。
|
|||
|
|
|
|||
|
|
## 待确认项
|
|||
|
|
|
|||
|
|
- 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。
|
|||
|
|
- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](./backend/05-integration-layer.md)),需要与后端一起定。
|
|||
|
|
- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。
|
|||
|
|
|
|||
|
|
## 参考链接
|
|||
|
|
|
|||
|
|
- [webview_flutter | Dart package](https://pub.dev/packages/webview_flutter)
|
|||
|
|
- [webview_flutter: JavaScript Channel](https://pub.dev/packages/webview_flutter#javascript-channels)
|
|||
|
|
- [AndroidWebViewController.setOnShowFileSelector](https://pub.dev/documentation/webview_flutter_android/latest/webview_flutter_android/AndroidWebViewController/setOnShowFileSelector.html)
|
|||
|
|
- [OWASP MASVS:WebView 安全](https://mas.owasp.org/MASVS/)
|
|||
|
|
- [PRD §7 Embedded H5 接入规范](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md)
|