vip 接口 游戏及 重新安装等问题

This commit is contained in:
roxy 2026-04-29 19:23:51 +08:00
parent 7d12417116
commit 9f4df82c42
11 changed files with 547 additions and 85 deletions

View File

@ -38,11 +38,15 @@
</intent>
<package android:name="com.snapchat.android" />
</queries>
<application
android:name="${applicationName}"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true">
<application
android:name="${applicationName}"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include
domain="sharedpref"
path="." />
</full-backup-content>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include
domain="sharedpref"
path="." />
</cloud-backup>
<device-transfer>
<include
domain="sharedpref"
path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -1,13 +1,91 @@
import Flutter
import UIKit
import Flutter
import Security
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
registerDurableAuthStorageChannel()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func registerDurableAuthStorageChannel() {
guard let controller = window?.rootViewController as? FlutterViewController else {
return
}
let channel = FlutterMethodChannel(
name: "com.org.yumiparty/durable_auth_storage",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
guard
let self,
let args = call.arguments as? [String: Any],
let key = args["key"] as? String
else {
result(FlutterError(code: "bad_args", message: "Missing key", details: nil))
return
}
switch call.method {
case "write":
guard let value = args["value"] as? String else {
result(FlutterError(code: "bad_args", message: "Missing value", details: nil))
return
}
self.writeKeychainValue(value, key: key)
result(nil)
case "read":
result(self.readKeychainValue(key: key))
case "delete":
self.deleteKeychainValue(key: key)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func keychainQuery(key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.org.yumiparty.durable_auth_storage",
kSecAttrAccount as String: key
]
}
private func writeKeychainValue(_ value: String, key: String) {
deleteKeychainValue(key: key)
guard let data = value.data(using: .utf8) else {
return
}
var query = keychainQuery(key: key)
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(query as CFDictionary, nil)
}
private func readKeychainValue(key: String) -> String? {
var query = keychainQuery(key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard
status == errSecSuccess,
let data = result as? Data
else {
return nil
}
return String(data: data, encoding: .utf8)
}
private func deleteKeychainValue(key: String) {
SecItemDelete(keychainQuery(key: key) as CFDictionary)
}
}

View File

@ -27,6 +27,7 @@ import 'app/routes/sc_routes.dart';
import 'app/routes/sc_lk_application.dart';
import 'shared/tools/sc_deep_link_handler.dart';
import 'shared/tools/sc_deviceId_utils.dart';
import 'shared/data_sources/sources/local/user_manager.dart';
import 'modules/splash/splash_page.dart';
import 'services/general/sc_app_general_manager.dart';
import 'services/payment/apple_payment_manager.dart';
@ -94,6 +95,7 @@ Future<void> _prepareAppShell() async {
SCKeybordUtil.init();
// 使
await _initStore();
await AccountStorage().restoreDurableSessionIfNeeded();
await SCDeviceIdUtils.initDeviceinfo();
_configureImageCache();
}

View File

@ -346,24 +346,23 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
return 0;
}
double _calculateBodyHeight(BuildContext context, double headerHeight) {
double _calculateBodyHeight(BuildContext context) {
final screenMode = _resolveScreenMode();
final maxDialogHeight = resolveRoomGameAvailableHeight(
context,
reservedTop: 24.w,
reservedTop: 12.w,
);
final maxBodyHeight = maxDialogHeight - headerHeight;
if (maxBodyHeight <= 0) {
if (maxDialogHeight <= 0) {
return 0;
}
if (screenMode == 'full') {
return maxBodyHeight;
return maxDialogHeight;
}
return clampRoomGameHeight(
_calculatePreferredBodyHeight(context),
maxBodyHeight,
maxDialogHeight,
);
}
@ -544,8 +543,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
@override
Widget build(BuildContext context) {
final headerHeight = 44.w;
final bodyHeight = _calculateBodyHeight(context, headerHeight);
final bodyHeight = _calculateBodyHeight(context);
final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context);
return PopScope(
canPop: false,
@ -566,7 +564,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
),
child: Container(
width: ScreenUtil().screenWidth,
height: bodyHeight + headerHeight + bottomFrameHeight,
height: bodyHeight + bottomFrameHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
@ -574,13 +572,6 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
left: 0,
right: 0,
top: 0,
height: headerHeight,
child: _buildHeader(),
),
Positioned(
left: 0,
right: 0,
top: headerHeight,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
@ -614,36 +605,6 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
);
}
Widget _buildHeader() {
return Container(
color: const Color(0xFF102D28),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(
child: Text(
widget.game.name.isEmpty ? 'Leader Game' : widget.game.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () {
unawaited(_closeAndExit());
},
icon: Icon(Icons.close, color: Colors.white, size: 20.w),
splashRadius: 18.w,
),
],
),
);
}
Widget _buildErrorState() {
return Positioned.fill(
child: Container(

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
@ -6,7 +7,11 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/tools/sc_version_utils.dart';
import 'package:yumi/shared/tools/sc_google_auth_utils.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_auth_type.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
@ -448,6 +453,15 @@ class _SplashPageState extends State<SplashPage> {
var token = AccountStorage().getToken();
if (user != null && token.isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
return;
}
final restoredUser = await _tryRestoreLoginSession();
if (!mounted) {
return;
}
if (restoredUser != null &&
(restoredUser.token ?? "").trim().isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
} else {
SCNavigatorUtils.pushLoginIfNeeded(context, replace: true);
}
@ -459,6 +473,28 @@ class _SplashPageState extends State<SplashPage> {
}
}
Future<SocialChatLoginRes?> _tryRestoreLoginSession() async {
if (!Platform.isAndroid) {
return null;
}
try {
final googleUser = await SCGoogleAuthService.signInSilently();
final uid = googleUser?.uid ?? "";
if (uid.isEmpty) {
return null;
}
final user = await SCAccountRepository().loginForChannel(
SCAuthType.GOOGLE.name,
uid,
);
AccountStorage().setCurrentUser(user);
return user;
} catch (e) {
debugPrint('restore login session failed: $e');
return null;
}
}
///
void _cancelTimer() {
// `Timer``cancel`

View File

@ -58,6 +58,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
final status = results[1] as SCVipStatusRes;
final resolvedStatus = status.levelInt > 0 ? status : home.state;
final selectedLevel = _resolveInitialLevel(home, resolvedStatus);
_logLoadedVipData(home, resolvedStatus, selectedLevel);
if (!mounted) return;
setState(() {
@ -122,6 +123,11 @@ class _VipDetailPageState extends State<VipDetailPage> {
if (_home?.configured == false ||
config?.enabled == false ||
config?.canPurchase != true) {
debugPrint(
'[VIP][Page] skip preview level=$level '
'configured=${_home?.configured} enabled=${config?.enabled} '
'canPurchase=${config?.canPurchase}',
);
return;
}
@ -163,6 +169,10 @@ class _VipDetailPageState extends State<VipDetailPage> {
if (!mounted) return;
if (purchase.success == true) {
debugPrint(
'[VIP][Page] purchase success targetLevel=$_selectedLevel '
'orderId=${purchase.order?.id} paidGold=${purchase.order?.paidGold}',
);
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
profileManager.fetchUserProfileData(loadGuardCount: false);
profileManager.balance();
@ -183,6 +193,31 @@ class _VipDetailPageState extends State<VipDetailPage> {
return 'vip-buy-$userId-${DateTime.now().millisecondsSinceEpoch}';
}
void _logLoadedVipData(
SCVipHomeRes home,
SCVipStatusRes? status,
int selectedLevel,
) {
debugPrint(
'[VIP][Page] home loaded configured=${home.configured} '
'levels=${home.levels.length} selectedLevel=$selectedLevel '
'statusActive=${status?.active} statusLevel=${status?.level} '
'statusExpireAt=${status?.expireAt}',
);
if (home.configured != true) {
debugPrint(
'[VIP][Page][Warn] vip home configured=${home.configured}; '
'check backend VIP config for sysOrigin=${home.sysOrigin}',
);
}
if (home.levels.isEmpty) {
debugPrint(
'[VIP][Page][Warn] vip home levels is empty; '
'frontend has no level data to render.',
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@ -0,0 +1,83 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class DurableAuthStorage {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/durable_auth_storage',
);
static const String _sessionKey = 'auth_session_v1';
static Future<void> saveSession({
required String token,
required String currentUserJson,
}) async {
if (!Platform.isIOS) {
return;
}
if (token.trim().isEmpty || currentUserJson.trim().isEmpty) {
await clearSession();
return;
}
await _invokeSafely('write', <String, Object>{
'key': _sessionKey,
'value': jsonEncode(<String, String>{
'token': token,
'currentUser': currentUserJson,
}),
});
}
static Future<DurableAuthSession?> readSession() async {
if (!Platform.isIOS) {
return null;
}
try {
final raw = await _channel.invokeMethod<String>('read', <String, Object>{
'key': _sessionKey,
});
if (raw == null || raw.trim().isEmpty) {
return null;
}
final json = jsonDecode(raw);
if (json is! Map) {
return null;
}
final token = (json['token'] as String? ?? '').trim();
final currentUserJson = json['currentUser'] as String? ?? '';
if (token.isEmpty || currentUserJson.trim().isEmpty) {
return null;
}
return DurableAuthSession(token: token, currentUserJson: currentUserJson);
} catch (_) {
return null;
}
}
static Future<void> clearSession() async {
if (!Platform.isIOS) {
return;
}
await _invokeSafely('delete', <String, Object>{'key': _sessionKey});
}
static Future<void> _invokeSafely(
String method,
Map<String, Object> arguments,
) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} catch (_) {}
}
}
class DurableAuthSession {
const DurableAuthSession({
required this.token,
required this.currentUserJson,
});
final String token;
final String currentUserJson;
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
@ -5,6 +6,7 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_message_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/durable_auth_storage.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
@ -48,6 +50,12 @@ class AccountStorage {
String userJson = jsonEncode(_currentUser?.toJson());
if (userJson.isNotEmpty) {
DataPersistence.setCurrentUser(userJson);
unawaited(
DurableAuthStorage.saveSession(
token: _currentUser!.token ?? "",
currentUserJson: userJson,
),
);
}
}
@ -119,6 +127,30 @@ class AccountStorage {
return token;
}
Future<void> restoreDurableSessionIfNeeded() async {
final localToken = DataPersistence.getToken().trim();
final localUserJson = DataPersistence.getCurrentUser().trim();
if (localToken.isNotEmpty && localUserJson.isNotEmpty) {
return;
}
final session = await DurableAuthStorage.readSession();
if (session == null) {
return;
}
token = session.token;
await DataPersistence.setToken(session.token);
await DataPersistence.setCurrentUser(session.currentUserJson);
try {
_currentUser = SocialChatLoginRes.fromJson(
jsonDecode(session.currentUserJson),
);
} catch (_) {
_currentUser = null;
}
}
void setToken(String tk) {
token = tk;
DataPersistence.setToken(token);
@ -129,6 +161,7 @@ class AccountStorage {
_currentUser = null;
DataPersistence.setToken("");
DataPersistence.setCurrentUser("");
unawaited(DurableAuthStorage.clearSession());
}
///退

View File

@ -1,7 +1,17 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/business_logic/repositories/vip_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
const String _vipApiHost = String.fromEnvironment(
'VIP_API_HOST',
defaultValue: '',
);
class SCVipRepositoryImp implements SocialChatVipRepository {
static SCVipRepositoryImp? _instance;
@ -13,20 +23,38 @@ class SCVipRepositoryImp implements SocialChatVipRepository {
@override
Future<SCVipStatusRes> vipStatus() async {
final result = await http.get<SCVipStatusRes>(
'/app/vip/status',
fromJson: (json) => SCVipStatusRes.fromJson(json),
);
return result;
final path = _vipPath('/status');
_logRequest('GET', path);
try {
final result = await http.get<SCVipStatusRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipStatusRes.fromJson(json),
);
_logResponse('GET', path, _statusSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
Future<SCVipHomeRes> vipHome() async {
final result = await http.get<SCVipHomeRes>(
'/app/vip/home',
fromJson: (json) => SCVipHomeRes.fromJson(json),
);
return result;
final path = _vipPath('/home');
_logRequest('GET', path);
try {
final result = await http.get<SCVipHomeRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipHomeRes.fromJson(json),
);
_logResponse('GET', path, _homeSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
@ -46,12 +74,21 @@ class SCVipRepositoryImp implements SocialChatVipRepository {
params['targetLevelCode'] = targetLevelCode;
}
final result = await http.post<SCVipPreviewRes>(
'/app/vip/preview',
data: params,
fromJson: (json) => SCVipPreviewRes.fromJson(json),
);
return result;
final path = _vipPath('/preview');
_logRequest('POST', path, body: params);
try {
final result = await http.post<SCVipPreviewRes>(
path,
data: params,
extra: _vipExtra(),
fromJson: (json) => SCVipPreviewRes.fromJson(json),
);
_logResponse('POST', path, _previewSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: params);
rethrow;
}
}
@override
@ -59,21 +96,195 @@ class SCVipRepositoryImp implements SocialChatVipRepository {
required int targetLevel,
required String bizIdempotentKey,
}) async {
final result = await http.post<SCVipPurchaseRes>(
'/app/vip/purchase',
data: {'targetLevel': targetLevel, 'bizIdempotentKey': bizIdempotentKey},
fromJson: (json) => SCVipPurchaseRes.fromJson(json),
);
return result;
final path = _vipPath('/purchase');
final body = {
'targetLevel': targetLevel,
'bizIdempotentKey': bizIdempotentKey,
};
_logRequest('POST', path, body: body);
try {
final result = await http.post<SCVipPurchaseRes>(
path,
data: body,
extra: _vipExtra(),
fromJson: (json) => SCVipPurchaseRes.fromJson(json),
);
_logResponse('POST', path, _purchaseSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: body);
rethrow;
}
}
@override
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20}) async {
final result = await http.get<SCVipOrdersRes>(
'/app/vip/orders',
queryParams: {'cursor': cursor, 'limit': limit},
fromJson: (json) => SCVipOrdersRes.fromJson(json),
final path = _vipPath('/orders');
final query = {'cursor': cursor, 'limit': limit};
_logRequest('GET', path, query: query);
try {
final result = await http.get<SCVipOrdersRes>(
path,
queryParams: query,
extra: _vipExtra(),
fromJson: (json) => SCVipOrdersRes.fromJson(json),
);
_logResponse('GET', path, _ordersSummary(result));
return result;
} catch (error) {
_logError('GET', path, error, query: query);
rethrow;
}
}
static bool get _hasExplicitVipApiHost => _vipApiHost.trim().isNotEmpty;
static String get _activeBaseUrl =>
_hasExplicitVipApiHost ? _vipApiHost.trim() : SCGlobalConfig.apiHost;
static String _vipPath(String suffix) {
final baseUrl = _activeBaseUrl.trim();
final parsed = Uri.tryParse(baseUrl);
final pathSegments =
parsed?.pathSegments.where((segment) => segment.isNotEmpty).toSet() ??
<String>{};
final baseAlreadyContainsGo = pathSegments.contains('go');
if (_hasExplicitVipApiHost || baseAlreadyContainsGo) {
return '/app/vip$suffix';
}
return '/go/app/vip$suffix';
}
static Map<String, dynamic>? _vipExtra() {
if (!_hasExplicitVipApiHost) {
return null;
}
return {BaseNetworkClient.baseUrlOverrideKey: _vipApiHost.trim()};
}
static void _logRequest(
String method,
String path, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Request] $method $path '
'baseUrl=$_activeBaseUrl query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})}',
);
return result;
}
static void _logResponse(
String method,
String path,
Map<String, dynamic> summary,
) {
debugPrint('[VIP][Response] $method $path summary=${_json(summary)}');
}
static void _logError(
String method,
String path,
Object error, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Error] $method $path query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})} error=$error',
);
if (error is DioException) {
debugPrint(
'[VIP][DioError] $method $path '
'uri=${error.requestOptions.uri} '
'statusCode=${error.response?.statusCode} '
'message=${error.message} '
'response=${_json(error.response?.data)}',
);
}
}
static Map<String, dynamic> _statusSummary(SCVipStatusRes result) {
return {
'active': result.active,
'status': result.status,
'level': result.level,
'levelCode': result.levelCode,
'expireAt': result.expireAt,
'hasBadge': result.badge?.url?.isNotEmpty == true,
'hasAvatarFrame': result.avatarFrame?.url?.isNotEmpty == true,
'hasEntryEffect': result.entryEffect?.url?.isNotEmpty == true,
'hasChatBubble': result.chatBubble?.url?.isNotEmpty == true,
'hasFloatPicture': result.floatPicture?.url?.isNotEmpty == true,
};
}
static Map<String, dynamic> _homeSummary(SCVipHomeRes result) {
return {
'configured': result.configured,
'state': result.state == null ? null : _statusSummary(result.state!),
'levelsCount': result.levels.length,
'levels':
result.levels
.map(
(level) => {
'level': level.level,
'levelCode': level.levelCode,
'enabled': level.enabled,
'durationDays': level.durationDays,
'priceGold': level.priceGold,
'canPurchase': level.canPurchase,
'payableGold': level.payableGold,
'hasBadge': level.badge?.url?.isNotEmpty == true,
},
)
.toList(),
};
}
static Map<String, dynamic> _previewSummary(SCVipPreviewRes result) {
return {
'orderType': result.orderType,
'currentLevel': result.currentLevel,
'targetLevel': result.targetLevel,
'durationDays': result.durationDays,
'originalPriceGold': result.originalPriceGold,
'payableGold': result.payableGold,
'expireAt': result.expireAt,
};
}
static Map<String, dynamic> _purchaseSummary(SCVipPurchaseRes result) {
final order = result.order;
return {
'success': result.success,
'order':
order == null
? null
: {
'id': order.id,
'orderType': order.orderType,
'fromLevel': order.fromLevel,
'toLevel': order.toLevel,
'paidGold': order.paidGold,
'status': order.status,
'expireAt': order.expireAt,
},
'state': result.state == null ? null : _statusSummary(result.state!),
};
}
static Map<String, dynamic> _ordersSummary(SCVipOrdersRes result) {
return {'recordsCount': result.records.length, 'total': result.total};
}
static String _json(dynamic value) {
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}
}