feat(app): session restore on startup — no forced re-login after app restart
用户反馈:关闭 App 重开每次都要登录。根因:会话已持久化(安全存储, 服务端 TTL 30 天),但启动固定落在登录页——无人消费已存会话。 - _StartupGate(home):watch sessionAccount——在场直进 HomePage,离场 LoginPage;状态驱动(401/登出清会话后 gate 自动重建,不依赖导航时序) - onUnauthorized 保留 push 兜底(home 路由被显式导航替换后的 401 场景) - 测试适配(boot 401 两项重写通过;397/397 全绿) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,7 @@ import 'locale_controller.dart';
|
|||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// 根导航键:[ApiClient.onUnauthorized](非 UI 上下文触发)跳转 /login 用。
|
/// 根导航键:[ApiClient.onUnauthorized](非 UI 上下文触发)跳转 /login 用。
|
||||||
final GlobalKey<NavigatorState> rootNavigatorKey =
|
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
GlobalKey<NavigatorState>();
|
|
||||||
|
|
||||||
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。
|
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。
|
||||||
///
|
///
|
||||||
@@ -36,9 +35,12 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
ref.read(apiClientProvider).onUnauthorized = () {
|
ref.read(apiClientProvider).onUnauthorized = () {
|
||||||
rootNavigatorKey.currentState
|
rootNavigatorKey.currentState?.pushNamedAndRemoveUntil(
|
||||||
?.pushNamedAndRemoveUntil('/login', (route) => false);
|
'/login',
|
||||||
// 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop。
|
(route) => false,
|
||||||
|
);
|
||||||
|
// 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop;启动门
|
||||||
|
// (home 路由仍在栈上时)随会话离场重建为登录页。
|
||||||
ref.read(sessionAccountProvider.notifier).refresh();
|
ref.read(sessionAccountProvider.notifier).refresh();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,39 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
|
|||||||
'/password-change': (context) => const PasswordChangePage(),
|
'/password-change': (context) => const PasswordChangePage(),
|
||||||
'/server-history': (context) => const ServerHistoryPage(),
|
'/server-history': (context) => const ServerHistoryPage(),
|
||||||
},
|
},
|
||||||
home: const LoginPage(),
|
// 启动门(UI06 用户反馈 2026-09-23:重启 App 不应强制重登):有未过期
|
||||||
|
// 会话直接进主页,否则登录页。
|
||||||
|
home: const _StartupGate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动门:读本地会话——未过期直接进 HomePage,否则 LoginPage。
|
||||||
|
///
|
||||||
|
/// 会话已持久化(SecureSessionStore,服务端 TTL 默认 30 天);此前的缺口
|
||||||
|
/// 是启动固定落在登录页。token 实际失效(服务端已撤销)时由 ApiClient 401
|
||||||
|
/// 兜底:清会话并整栈回登录页(onUnauthorized),过期本地判断只用于避免
|
||||||
|
/// 「必 401 的闪跳」。
|
||||||
|
class _StartupGate extends ConsumerStatefulWidget {
|
||||||
|
const _StartupGate();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_StartupGate> createState() => _StartupGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartupGateState extends ConsumerState<_StartupGate> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 状态驱动(声明式):watch 会话账号——在场进 HomePage,离场回
|
||||||
|
// LoginPage。401/登出清会话后 gate 自动重建,不依赖导航时序;home
|
||||||
|
// 路由已被显式导航替换(登录成功 pushReplacement '/home' 等)时,
|
||||||
|
// 401 兜底走 onUnauthorized 的 push。
|
||||||
|
final account = ref.watch(sessionAccountProvider);
|
||||||
|
return account.maybeWhen(
|
||||||
|
skipLoadingOnReload: false,
|
||||||
|
orElse: () =>
|
||||||
|
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||||
|
data: (account) => account != null ? const HomePage() : const LoginPage(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:photo_live/app/app.dart';
|
import 'package:photo_live/app/app.dart';
|
||||||
|
import 'package:photo_live/pages/login_page.dart';
|
||||||
import 'package:photo_live/core/api_client.dart';
|
import 'package:photo_live/core/api_client.dart';
|
||||||
import 'package:photo_live/core/error_codes.dart';
|
import 'package:photo_live/core/error_codes.dart';
|
||||||
import 'package:photo_live/core/session_store.dart';
|
import 'package:photo_live/core/session_store.dart';
|
||||||
import 'package:photo_live/pages/login_page.dart';
|
|
||||||
|
|
||||||
import '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
@@ -43,10 +43,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// 先抵 /home 再触发 401:验证清栈(回 /login 而非叠加路由)。
|
// UI06 启动门:有会话直接渲染 HomePage(无须手动 push)。
|
||||||
rootNavigatorKey.currentState!
|
|
||||||
.pushNamedAndRemoveUntil('/home', (route) => false);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// 触发 401 但不在测试体内 await(fake-async 下时钟由 pump 推进,
|
// 触发 401 但不在测试体内 await(fake-async 下时钟由 pump 推进,
|
||||||
// 阻塞等待会死锁);AppException 由 ignore 吞掉,断言在下方。
|
// 阻塞等待会死锁);AppException 由 ignore 吞掉,断言在下方。
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:photo_live/app/app.dart';
|
import 'package:photo_live/app/app.dart';
|
||||||
|
import 'package:photo_live/core/api_client.dart';
|
||||||
|
import 'helpers/fakes.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('boots to login page with localized copy', (tester) async {
|
testWidgets('boots to login page with localized copy', (tester) async {
|
||||||
await tester.pumpWidget(const ProviderScope(child: PhotoLiveApp()));
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
// UI06 启动门读会话:生产 SecureSessionStore 平台通道在测试环境
|
||||||
|
// 挂起——替身空会话(→ 登录页)。
|
||||||
|
overrides: [
|
||||||
|
sessionStoreProvider.overrideWithValue(FakeSessionStore()),
|
||||||
|
],
|
||||||
|
child: const PhotoLiveApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// UI06 启动门:异步读本地会话(空 → 登录页)——多 pump 一帧等完成。
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
// 测试环境默认 locale 为 en:AppBar 标题与登录按钮均为英文文案。
|
// 测试环境默认 locale 为 en:AppBar 标题与登录按钮均为英文文案。
|
||||||
expect(find.text('Photo Live'), findsOneWidget);
|
expect(find.text('Photo Live'), findsOneWidget);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
const server = createServer((_q, res) => {
|
||||||
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||||
|
res.end(`<!doctype html><html><head><meta charset="utf-8" /></head><body style="min-height:900px">
|
||||||
|
<script src="http://219.157.255.213:32006/embed/v2/loader.js" data-slot="dg0mo4i5gov39g1vv0wgisgb" defer></script></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(3991, '127.0.0.1', r));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
await page.goto('http://127.0.0.1:3991');
|
||||||
|
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
await frame.waitFor({ timeout: 20000 });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
|
||||||
|
// ① 标题条显示
|
||||||
|
const title = await inside.locator('.stream-title').textContent();
|
||||||
|
console.log('[title] =', title?.trim());
|
||||||
|
|
||||||
|
// ② 点击照片 → 宿主弹框(不再新标签)
|
||||||
|
const box = await frame.boundingBox();
|
||||||
|
await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 140);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
let popupOpened = false;
|
||||||
|
page.once('popup', () => { popupOpened = true; });
|
||||||
|
await inside.locator('a.stream-photo').first().click();
|
||||||
|
const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]');
|
||||||
|
await modalFrame.waitFor({ timeout: 8000 });
|
||||||
|
console.log('[modal] appeared = true; popup opened =', popupOpened);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? '';
|
||||||
|
console.log('[modal] detail url =', detailUrl.slice(0, 80));
|
||||||
|
await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' });
|
||||||
|
|
||||||
|
// ③ 关闭
|
||||||
|
await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click();
|
||||||
|
await modalFrame.waitFor({ state: 'detached', timeout: 5000 });
|
||||||
|
console.log('[modal] closed = true');
|
||||||
|
server.close(); await browser.close();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
const server = createServer((_q, res) => {
|
||||||
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||||
|
res.end(`<!doctype html><html><head><meta charset="utf-8" /></head><body style="min-height:900px">
|
||||||
|
<h1>Host page</h1>
|
||||||
|
<script src="http://219.157.255.213:32006/embed/v2/loader.js" data-slot="oqh763sbarka2lw6u1iqefu0" defer></script></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(3988, '127.0.0.1', r));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
await page.goto('http://127.0.0.1:3988');
|
||||||
|
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
await frame.waitFor({ timeout: 20000 });
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
const title = await inside.locator('.stream-title').textContent().catch(() => null);
|
||||||
|
console.log('[title] =', title?.trim() ?? '(none)');
|
||||||
|
|
||||||
|
const box = await frame.boundingBox();
|
||||||
|
await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 150);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
let popupOpened = false;
|
||||||
|
page.once('popup', () => { popupOpened = true; });
|
||||||
|
await inside.locator('a.stream-photo').first().click();
|
||||||
|
const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]');
|
||||||
|
await modalFrame.waitFor({ timeout: 8000 });
|
||||||
|
console.log('[modal] appeared; popup =', popupOpened);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? '';
|
||||||
|
console.log('[modal] detail =', detailUrl.slice(0, 90));
|
||||||
|
await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' });
|
||||||
|
await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click();
|
||||||
|
await modalFrame.waitFor({ state: 'detached', timeout: 5000 });
|
||||||
|
console.log('[modal] closed');
|
||||||
|
server.close(); await browser.close();
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
const server = createServer((_q, res) => {
|
||||||
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||||
|
res.end(`<!doctype html><html><head><meta charset="utf-8" /></head><body style="min-height:900px">
|
||||||
|
<h1>Host page</h1>
|
||||||
|
<script src="http://219.157.255.213:32006/embed/v2/loader.js" data-slot="oqh763sbarka2lw6u1iqefu0" defer></script></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(3987, '127.0.0.1', r));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
await page.goto('http://127.0.0.1:3987');
|
||||||
|
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
await frame.waitFor({ timeout: 20000 });
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
console.log('[title] =', (await inside.locator('.stream-title').textContent().catch(() => '(none)'))?.trim());
|
||||||
|
|
||||||
|
const box = await frame.boundingBox();
|
||||||
|
await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 150);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
let popupOpened = false;
|
||||||
|
page.once('popup', () => { popupOpened = true; });
|
||||||
|
await inside.locator('a.stream-photo').first().click({ force: true, noWaitAfter: true });
|
||||||
|
const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]');
|
||||||
|
await modalFrame.waitFor({ timeout: 8000 });
|
||||||
|
console.log('[modal] appeared; popup =', popupOpened);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? '';
|
||||||
|
console.log('[modal] detail =', detailUrl.slice(0, 90));
|
||||||
|
await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' });
|
||||||
|
await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click();
|
||||||
|
await modalFrame.waitFor({ state: 'detached', timeout: 5000 });
|
||||||
|
console.log('[modal] closed');
|
||||||
|
server.close(); await browser.close();
|
||||||
Reference in New Issue
Block a user