From 44c4a1daae6ca22177b3438605053a7a90e4d64b Mon Sep 17 00:00:00 2001 From: chuxuehaocai Date: Tue, 29 Sep 2026 13:47:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=9E=E6=8E=A5=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=9A=84=E4=BC=9A=E8=AF=9D=E5=9C=A8=20isLogi?= =?UTF-8?q?n=20=E6=97=B6=E7=9B=B4=E6=8E=A5=E5=BC=80=E6=94=BE=E7=A5=A8?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E9=A3=8E=E9=99=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 恢复时除了 GetUserDataApi 再拉一次 GetUserPreviewApi:服务器说 isLogin 为 true 就把这次会话当已登录,_gameLogin 落地,票据与风险页按「登录后 60 秒冷却」计时 (loginDateTime 取恢复当下),不必再发一次 UserLoginApi - GetUserPreviewApi 的响应里没有 loginId,而 UpsertUserAll 要拿它当 playlogId, 所以导出连接信息时把本机的 loginId 一起带过去,恢复端优先用这个值, 拉不到才退回 preview 里的字段(多半是 0) - 只有 Cookie、preview 拉不到、或服务器说没在登录中时仍按只读处理, 提示横幅与 SnackBar 文案区分「可写」和「只能读」两种恢复结果 - HomePage 的恢复入口收成 restored: SessionRestoreResult?,把 preview 与 userData 一起当种子,省掉两次重复请求 --- lib/config/strings.dart | 9 ++-- lib/models/session_model.dart | 79 +++++++++++++++++++++++++++++------ lib/models/session_share.dart | 13 +++++- lib/models/user_preview.dart | 8 +++- lib/pages/home_page.dart | 25 +++++------ lib/pages/login_page.dart | 16 +++---- test/session_share_test.dart | 10 +++++ 7 files changed, 123 insertions(+), 37 deletions(-) diff --git a/lib/config/strings.dart b/lib/config/strings.dart index e8d0ccb..c211de2 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -39,11 +39,14 @@ class AppStrings { static const restoringSession = '恢复中...'; static const sessionShareDetected = '已识别为连接信息(实验性功能),点击「恢复会话」直接续上这次登录。'; static const sessionRestoreFailed = '会话恢复失败'; - static const sessionRestoreCookie = '已复用连接信息里的 JSESSIONID,没有重新登录'; + static const sessionRestoreCookie = + '已复用连接信息里的 JSESSIONID,这次会话仍在登录中:60 秒冷却后票据与风险功能可直接使用'; + static const sessionRestoreReadOnly = + '已复用连接信息里的 JSESSIONID,但服务器显示这次会话没在登录中,只能读取数据'; static const sessionRestoreToken = 'Cookie 不可用,已用令牌重新登录'; static const sessionRestoredNotice = - '这是用连接信息恢复的会话:JSESSIONID 只用于读取数据,' - '票据与风险页的写入操作需要先重新登录。'; + '这是用连接信息恢复的会话,服务器显示它当前不在登录中:JSESSIONID 只用于读取数据,' + '票据与风险页的写入需要重新登录。'; // Home static const logoutTooltip = '退出登录'; diff --git a/lib/models/session_model.dart b/lib/models/session_model.dart index 0fa0040..6cc74fe 100644 --- a/lib/models/session_model.dart +++ b/lib/models/session_model.dart @@ -5,6 +5,7 @@ import '../services/api_service.dart'; import '../services/title_api_service.dart'; import 'session_share.dart'; import 'user_data.dart'; +import 'user_preview.dart'; /// [SessionModel.restoreFromShare] 的结果。 class SessionRestoreResult { @@ -12,10 +13,20 @@ class SessionRestoreResult { /// 拿到了一份新 Cookie。 final bool reusedCookie; - /// 沿用旧会话时顺带拉到的用户数据(HomePage 直接展示,省一次请求)。 + /// 沿用旧会话时顺带拉到的用户数据与概要(HomePage 直接展示,省一次请求)。 final UserDataBean? userData; + final UserPreviewDataBean? preview; - const SessionRestoreResult({required this.reusedCookie, this.userData}); + /// 服务器认为这次会话仍在登录中(`preview.isLogin`),或刚用令牌登录成功。 + /// 只有为 `true` 时票据与风险页才开放写入,并且照常吃 60 秒登录后冷却。 + final bool loggedIn; + + const SessionRestoreResult({ + required this.reusedCookie, + this.userData, + this.preview, + this.loggedIn = false, + }); } class SessionModel extends ChangeNotifier { @@ -90,7 +101,12 @@ class SessionModel extends ChangeNotifier { SessionShare exportConnectionInfo({ required int userId, required String token, - }) => SessionShare(userId: userId, token: token, cookie: _cookies); + }) => SessionShare( + userId: userId, + token: token, + loginId: _gameLogin?.loginId ?? 0, + cookie: _cookies, + ); /// 实验性:用连接信息恢复会话。 /// @@ -99,12 +115,10 @@ class SessionModel extends ChangeNotifier { Future restoreFromShare(SessionShare share) async { final cookie = share.jsessionid; if (cookie != null) { - final userData = await _probeCookieSession(share.userId, cookie); - if (userData != null) { - // _gameLogin 保持 null:这次没有 UserLoginApi,拿不到 loginId / - // loginDateTime,写入类功能页照旧提示「尚未登录游戏服务器」。 + final resumed = await _resumeCookieSession(share, cookie); + if (resumed != null) { notifyListeners(); - return SessionRestoreResult(reusedCookie: true, userData: userData); + return resumed; } } @@ -112,20 +126,59 @@ class SessionModel extends ChangeNotifier { throw const TitleApiException('连接信息里的 Cookie 已失效,且没有可用于重新登录的令牌'); } await loginGame(userId: share.userId, token: share.token); - return const SessionRestoreResult(reusedCookie: false); + // UserLoginApi 成功了,登录态直接可用,照常吃登录后 60 秒冷却。 + return const SessionRestoreResult(reusedCookie: false, loggedIn: true); } - /// 用导入的 Cookie 试拉一次 GetUserDataApi,会话不可用时返回 `null`。 - Future _probeCookieSession(int userId, String cookie) async { + /// 沿用连接信息里的 JSESSIONID 继续这次会话;服务器已经不认这份 Cookie 时 + /// 返回 `null`,由调用方退回去用令牌重新登录。 + Future _resumeCookieSession( + SessionShare share, + String cookie, + ) async { final service = TitleApiService.fromHolder(cookies: cookie); if (service == null) return null; + try { - final json = await service.getUserData(userId); + final json = await service.getUserData(share.userId); // 会话失效时服务器仍可能回一个不含 userData 的包,不能当成登录成功。 if (json['userData'] is! Map) return null; final userData = UserDataBean.fromJson(json); + + UserPreviewDataBean? preview; + if (share.hasToken) { + // isLogin 是服务器对「这次会话还挂在机上」的判定,风险页要用的 loginId + // 也在这里。令牌过期拉不到 preview 就只放开读取,按未登录处理。 + try { + preview = await service.getUserPreview( + userId: share.userId, + token: share.token, + ); + } on TitleApiException { + preview = null; + } + } updateCookies(service.cookies); - return userData; + + // 服务器说这次会话还挂在机上,就按已登录处理:loginDateTime 取当下, + // 风险页与票据照常吃「登录后 60 秒冷却」,不用重新 UserLoginApi。 + if (preview != null && preview.isLogin) { + _gameLogin = UserLoginResult( + token: share.token, + // 优先用导出端带过来的 loginId(那是服务器给这次登录的真实 id), + // 导出端没有才退回 preview 里的字段。 + loginId: share.loginId != 0 ? share.loginId : preview.loginId, + lastLoginDate: preview.lastLoginDate, + loginDateTime: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + return SessionRestoreResult( + reusedCookie: true, + userData: userData, + preview: preview, + loggedIn: _gameLogin != null, + ); } on TitleApiException { return null; } diff --git a/lib/models/session_share.dart b/lib/models/session_share.dart index 0486def..fcadf92 100644 --- a/lib/models/session_share.dart +++ b/lib/models/session_share.dart @@ -11,10 +11,19 @@ class SessionShare { final int userId; final String token; + /// 导出端这次登录的 `loginId`。GetUserPreviewApi 不回这个字段,而恢复端写 + /// UpsertUserAll 时要拿它当 playlogId,所以只能由导出端一起带上;没有就是 0。 + final int loginId; + /// 原始 Cookie 串,可能是 `JSESSIONID=...`,也可能带别的字段。 final String? cookie; - const SessionShare({required this.userId, required this.token, this.cookie}); + const SessionShare({ + required this.userId, + required this.token, + this.loginId = 0, + this.cookie, + }); /// 只取 `JSESSIONID`:传输层后续请求就靠它维持会话 /// (见 `TitleApiService._captureCookiesFromResponse`)。 @@ -41,6 +50,7 @@ class SessionShare { 'version': _version, 'userId': userId, 'token': token, + 'loginId': loginId, 'cookie': jsessionid, }; return base64Encode(utf8.encode(jsonEncode(map))); @@ -62,6 +72,7 @@ class SessionShare { final share = SessionShare( userId: (decoded['userId'] as num?)?.toInt() ?? 0, token: decoded['token'] as String? ?? '', + loginId: (decoded['loginId'] as num?)?.toInt() ?? 0, cookie: decoded['cookie'] as String?, ); // 两头都空的话这串信息什么都恢复不了。 diff --git a/lib/models/user_preview.dart b/lib/models/user_preview.dart index cb8f10b..6325011 100644 --- a/lib/models/user_preview.dart +++ b/lib/models/user_preview.dart @@ -2,6 +2,10 @@ class UserPreviewDataBean { final int userId; final String userName; final bool isLogin; + + /// 机台侧这次登录的 `loginId`(组装 UpsertUserAll 时当 playlogId 用)。 + /// 服务器没带这个字段时是 0。 + final int loginId; final String lastGameId; final String lastRomVersion; final String lastDataVersion; @@ -24,6 +28,7 @@ class UserPreviewDataBean { required this.userId, required this.userName, required this.isLogin, + this.loginId = 0, required this.lastGameId, required this.lastRomVersion, required this.lastDataVersion, @@ -66,6 +71,7 @@ class UserPreviewDataBean { userId: toInt(json['userId']), userName: toStr(json['userName']), isLogin: toBool(json['isLogin']), + loginId: toInt(json['loginId']), lastGameId: toStr(json['lastGameId']), lastRomVersion: toStr(json['lastRomVersion']), lastDataVersion: toStr(json['lastDataVersion']), @@ -87,4 +93,4 @@ class UserPreviewDataBean { errorId: toInt(json['errorId']), ); } -} \ No newline at end of file +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 26369cd..0febdec 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -25,18 +25,17 @@ class HomePage extends StatefulWidget { /// 会话由登录页粘贴「连接信息」恢复而来:登录态(或 Cookie)已经是现成的, /// 主页不要再发一次 UserLoginApi 去顶掉它。 - final bool sessionRestored; - - /// 恢复会话时验证 Cookie 顺手拉到的用户数据,直接展示、省一次请求。 - final UserDataBean? initialUserData; + /// + /// 其中 `loggedIn` 为 true 时(服务器说这次会话还挂在机上,或刚用令牌登录成功), + /// 票据与风险页照常开放,只按登录后 60 秒冷却计时。 + final SessionRestoreResult? restored; const HomePage({ super.key, required this.userId, required this.token, this.forcePreviewApi = false, - this.sessionRestored = false, - this.initialUserData, + this.restored, }); @override @@ -84,18 +83,20 @@ class _HomePageState extends State { _userData = null; }); - final seed = widget.initialUserData; - if (seed != null) { - // 恢复来的 JSESSIONID 已在 SessionModel._probeCookieSession 里验证过并 - // 带回全量数据,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。 + final restored = widget.restored; + final seed = restored?.userData; + if (restored != null && seed != null) { + // 恢复来的 JSESSIONID 已在 SessionModel._resumeCookieSession 里验证过,并 + // 顺带拉回数据与概要,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。 setState(() { + _preview = restored.preview; _userData = seed; _loading = false; }); return; } - if (!widget.sessionRestored) _session.setGameLogin(null); + if (restored == null) _session.setGameLogin(null); final service = TitleApiService.fromHolder(cookies: _session.cookies)!; @@ -514,7 +515,7 @@ class _HomePageState extends State { context, child: Column( children: [ - if (widget.sessionRestored && _session.gameLogin == null) + if (widget.restored != null && _session.gameLogin == null) AppNotice( AppStrings.sessionRestoredNotice, icon: Icons.science_outlined, diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 35c62ba..599bc51 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -126,6 +126,13 @@ class _LoginPageState extends State { } } + String _restoreMessage(SessionRestoreResult result) { + if (!result.reusedCookie) return AppStrings.sessionRestoreToken; + return result.loggedIn + ? AppStrings.sessionRestoreCookie + : AppStrings.sessionRestoreReadOnly; + } + /// 实验性:用主页导出的连接信息恢复会话。带可用 JSESSIONID 就直接续上那次登录, /// 否则退回用令牌重新登录。 Future _onRestoreSession() async { @@ -136,18 +143,13 @@ class _LoginPageState extends State { try { final result = await SessionModel.instance.restoreFromShare(share); if (!mounted) return; - context.showSnack( - result.reusedCookie - ? AppStrings.sessionRestoreCookie - : AppStrings.sessionRestoreToken, - ); + context.showSnack(_restoreMessage(result)); Navigator.of(context).push( MaterialPageRoute( builder: (_) => HomePage( userId: share.userId, token: share.token, - sessionRestored: true, - initialUserData: result.userData, + restored: result, ), ), ); diff --git a/test/session_share_test.dart b/test/session_share_test.dart index 7beffc7..8e61ddf 100644 --- a/test/session_share_test.dart +++ b/test/session_share_test.dart @@ -12,6 +12,7 @@ void main() { const share = SessionShare( userId: 1234567, token: 'SGWCMAIDABCDEF', + loginId: 4242, cookie: 'JSESSIONID=abc123DEF', ); @@ -20,9 +21,18 @@ void main() { expect(decoded, isNotNull); expect(decoded!.userId, 1234567); expect(decoded.token, 'SGWCMAIDABCDEF'); + expect(decoded.loginId, 4242); expect(decoded.jsessionid, 'JSESSIONID=abc123DEF'); }); + test('旧串里没有 loginId 时按 0 处理', () { + const share = SessionShare(userId: 9, token: 't', cookie: 'JSESSIONID=x'); + expect(share.loginId, 0); + + final decoded = SessionShare.tryDecode(share.encode())!; + expect(decoded.loginId, 0); + }); + test('只保留 JSESSIONID,忽略其它 Cookie', () { const share = SessionShare( userId: 1,