fix:对齐金值,主要注销,一键登录等方面修改

This commit is contained in:
jingyun committed 2026-09-08 17:42:17 +08:00
1 parent e29ed0a2c8
commit 6997a65c96
37 files changed
+3442 -514

No files matched your search

@@ -1,92 +1,143 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/network/auth_interceptor.dart';
import '../../../common/network/dio_provider.dart';
import '../../../common/storage/secure_identity_storage.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/network/dio_provider.dart';
import '../data/auth_models.dart';
import '../data/auth_api.dart';
import '../data/one_key_login_api.dart';
import '../data/auth_models.dart';
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
final authProvider =
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
(ref) => AuthController(ref.read(dioProvider)),
);
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
return AuthController(
ref.read(dioProvider),
identityStorage: ref.read(secureIdentityStorageProvider),
preferences: StorageService.to.preferences,
);
});
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
AuthController(this._dio) : super(const AsyncLoading()) {
_bootstrap();
AuthController(
this._dio, {
required SecureIdentityStorage identityStorage,
required SharedPreferences preferences,
bool autoBootstrap = true,
}) : _identityStorage = identityStorage,
_preferences = preferences,
super(const AsyncLoading()) {
if (autoBootstrap) _bootstrap();
}
final Dio _dio;
final SecureIdentityStorage _identityStorage;
final SharedPreferences _preferences;
AuthApi get _api => AuthApi(_dio);
Future<void> retryBootstrap() => _bootstrap();
Future<void> _bootstrap() async {
final token = await loadJwt();
state = const AsyncLoading();
final token = await _identityStorage.readJwt();
if (token == null || token.isEmpty) {
state = const AsyncData(GuestAuthState());
return;
}
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone.isEmpty ? '13800000000' : phone,
nickname: '金值用户',
createdAt: DateTime(2026, 6, 18),
),
token: token,
),
);
try {
final user = await _api.getProfile();
await _persistProfile(user);
state = AsyncData(LoggedInAuthState(user: user, token: token));
} catch (error, stackTrace) {
if (_isUnauthorized(error)) {
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
return;
}
final cached = _canUseCachedProfile(error) ? _readCachedProfile() : null;
if (cached != null) {
await _persistProfile(cached);
state = AsyncData(LoggedInAuthState(user: cached, token: token));
} else {
state = AsyncError(error, stackTrace);
}
}
}
Future<void> sendCode(String phone) async {
await AuthApi(_dio).sendSmsCode(phone);
}
Future<void> sendCode(String phone) => _api.sendSmsCode(phone);
Future<void> verifyCode(String phone, String code) async {
final reply = await AuthApi(_dio).verifySmsCode(phone, code);
await saveJwt(reply.accessToken);
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: reply.phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: reply.accessToken,
),
);
final reply = await _api.verifySmsCode(phone, code);
await _persistLogin(reply);
}
/// Test environment reuses Yanting's one-key verification endpoint until
/// 金值 has its own account contract.
Future<void> verifyOneKeyLogin(String loginToken) async {
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
await saveJwt(reply.token);
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
final reply = await _api.verifyOneKeyLogin(loginToken);
await _persistLogin(reply);
}
Future<void> _persistLogin(AuthLoginResult reply) async {
await _identityStorage.saveJwt(reply.accessToken);
await _persistProfile(reply.user);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: reply.phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: reply.token,
),
LoggedInAuthState(user: reply.user, token: reply.accessToken),
);
}
Future<void> logout() async {
await clearJwt();
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
}
Future<void> deleteAccount([String? code]) async {
await logout();
Future<void> deleteAccount(String code) async {
final normalized = code.trim();
if (normalized.isEmpty) {
throw ArgumentError.value(code, 'code', 'must not be empty');
}
await _api.deleteAccount(normalized);
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
}
Future<void> _persistProfile(JinzhiUser user) async {
await _preferences.setString(storageUserId, user.id);
await _preferences.setString(storageUserPhone, user.phone);
await _preferences.setString(storageUserCreatedAt, user.createdAt);
await _preferences.remove(_legacyMockLoggedInKey);
}
JinzhiUser? _readCachedProfile() {
final id = _preferences.getString(storageUserId)?.trim() ?? '';
final phone =
(_preferences.getString(storageUserPhone) ??
_preferences.getString(_legacyMockLoggedInKey) ??
'')
.trim();
final createdAt =
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
if (id.isEmpty && phone.isEmpty) return null;
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
}
Future<void> _clearLocalSession() async {
await _identityStorage.clearJwt();
await _preferences.remove(storageUserId);
await _preferences.remove(storageUserPhone);
await _preferences.remove(storageUserCreatedAt);
await _preferences.remove(_legacyMockLoggedInKey);
}
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
}
bool _isUnauthorized(Object error) =>
error is DioException && error.response?.statusCode == 401;
bool _canUseCachedProfile(Object error) {
if (error is! DioException) return false;
final status = error.response?.statusCode;
return status == null || status == 408 || status == 429 || status >= 500;
}
@@ -24,6 +24,9 @@ abstract interface class OneKeyLoginGateway {
void setWidgetCallback(OneKeyLoginCallback? callback);
/// 仅在用户点按一键登录后调用,预热运营商授权页。
Future<Object?> accelerate();
Future<void> requestLogin(UMCustomModel uiConfig);
void closeAuthPage();
@@ -65,6 +68,11 @@ class UmengOneKeyLoginGateway implements OneKeyLoginGateway {
}
}
@override
Future<Object?> accelerate() {
return UmengVerifySdk.accelerateLoginPageWithTimeout(3);
}
@override
Future<void> requestLogin(UMCustomModel uiConfig) async {
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
@@ -137,15 +145,25 @@ class OneKeyLoginSdkSession {
return _result.future;
}
// Availability checking already pre-accelerates the carrier page. Start
// the token request directly here; the SDK performs its own preflight when
// needed and reports its timeout/error through the registered callback.
// 预热必须由用户显式点击触发;不在启动或隐私同意阶段调用它。
unawaited(_startNativeLogin(uiConfig));
return _result.future;
}
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
try {
// 与 jinzhi-flutter-app 保持一致。未预热时 SDK 会在此处隐式预热,
// 在部分 Android 机型上更容易超时或直接回调失败。
final preheat = await _gateway.accelerate();
if (kDebugMode && preheat is Map && preheat['ret'] != null) {
// `ret` 是运营商预热失败码;插件原生层未将它输出到 logcat。
// 预热失败不阻断接下来的 getLoginToken,SDK 会自行再试一次。
debugPrint(
'OneKeyLogin preheat failed: vendor=${preheat['vendor']}, '
'ret=${preheat['ret']}',
);
}
if (_terminal) return;
_gateway.setTokenCallback(_handleTokenResult);
_gateway.setWidgetCallback((Object? _) {});
_gateway.setUiCallback(_handleUiResult);
@@ -186,6 +204,15 @@ class OneKeyLoginSdkSession {
void _handleTokenResult(Object? result) {
final code = extractUmengLoginCode(result);
if (kDebugMode && !isUmengTokenSuccess(result)) {
final map = result is Map ? result : const <Object?, Object?>{};
// 本地插件原先丢弃了 onTokenFailed 的 ret;只记录诊断字段,绝不记录 token。
debugPrint(
'OneKeyLogin Android/iOS SDK result: code=$code, '
'msg=${map['msg']}, vendor=${map['vendorName']}, '
'carrierError=${map['carrierFailedResultData']}',
);
}
// On iOS, 700002 only means the user tapped the authorization page's
// login button. It is followed by a separate 600000 (or 600xxx) token
// callback, so completing the session here discards that callback.
+60 -106
View File
@@ -1,14 +1,10 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../../common/network/dio_provider.dart';
import '../../../models/pb/jinzhi/v1/account.pb.dart' as pb;
import 'auth_models.dart';
/// 金值账号服务的 protobuf HTTP 契约。
///
/// 这个轻量编解码只覆盖短信登录需要的字段,避免把整套历史生成模型带入
/// 当前重建中的应用;字段号与金值 `account.proto` 保持一致。
/// 金值账户的 protobuf-over-HTTP 客户端。
class AuthApi {
const AuthApi(this._dio);
@@ -17,124 +13,82 @@ class AuthApi {
Future<int> sendSmsCode(String phone) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/send',
// Dio may JSON-encode a List<int> as `[10,11,...]`. The API expects
// the raw protobuf bytes, so preserve the binary payload explicitly.
data: Uint8List.fromList(_ProtoWriter.stringField(1, phone)),
data: pb.SendSmsCodeRequest(phone: phone).writeToBuffer(),
options: protobufOptions(),
);
return _ProtoMessage(response.data ?? const <int>[]).integer(1);
return pb.SendSmsCodeReply.fromBuffer(
response.data ?? const <int>[],
).expiresIn;
}
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/verify',
data: Uint8List.fromList([
..._ProtoWriter.stringField(1, phone),
..._ProtoWriter.stringField(2, code),
]),
data: pb.VerifySmsCodeRequest(phone: phone, code: code).writeToBuffer(),
options: protobufOptions(),
);
final reply = _ProtoMessage(response.data ?? const <int>[]);
final accessToken = reply.string(1).trim();
if (accessToken.isEmpty) {
throw const FormatException('登录响应缺少 access_token');
return AuthLoginResult.fromProto(
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
);
}
Future<AuthLoginResult> verifyOneKeyLogin(String loginToken) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/onekey/verify',
data: pb.VerifyOneKeyLoginRequest(loginToken: loginToken).writeToBuffer(),
options: protobufOptions(),
);
return AuthLoginResult.fromProto(
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
);
}
Future<JinzhiUser> getProfile() async {
final response = await _dio.get<List<int>>(
'/api/v1/me',
options: protobufOptions(),
);
final reply = pb.ProfileReply.fromBuffer(response.data ?? const <int>[]);
if (!reply.hasUser()) {
throw const FormatException('账号资料响应缺少 user');
}
final user = _ProtoMessage(reply.bytes(3));
return AuthLoginResult(
accessToken: accessToken,
phone: user.string(2).trim().isEmpty ? phone : user.string(2).trim(),
return _userFromProto(reply.user);
}
Future<void> deleteAccount(String smsCode) async {
await _dio.delete<List<int>>(
'/api/v1/me',
data: pb.DeleteAccountRequest(smsCode: smsCode).writeToBuffer(),
options: protobufOptions(),
);
}
}
class AuthLoginResult {
const AuthLoginResult({required this.accessToken, required this.phone});
const AuthLoginResult({
required this.accessToken,
required this.expiresIn,
required this.user,
});
final String accessToken;
final String phone;
}
final int expiresIn;
final JinzhiUser user;
class _ProtoWriter {
static List<int> stringField(int field, String value) => [
..._varint(field << 3 | 2),
..._varint(utf8.encode(value).length),
...utf8.encode(value),
];
static List<int> _varint(int value) {
final bytes = <int>[];
var remaining = value;
while (remaining > 0x7f) {
bytes.add(remaining & 0x7f | 0x80);
remaining >>= 7;
factory AuthLoginResult.fromProto(pb.VerifySmsCodeReply reply) {
if (reply.accessToken.trim().isEmpty || !reply.hasUser()) {
throw const FormatException('登录响应缺少 access_token 或 user');
}
bytes.add(remaining);
return bytes;
return AuthLoginResult(
accessToken: reply.accessToken.trim(),
expiresIn: reply.expiresIn,
user: _userFromProto(reply.user),
);
}
}
class _ProtoMessage {
_ProtoMessage(List<int> bytes) : _fields = _parse(bytes);
final Map<int, Object> _fields;
int integer(int field) => _fields[field] is int ? _fields[field]! as int : 0;
String string(int field) => _fields[field] is List<int>
? utf8.decode(_fields[field]! as List<int>, allowMalformed: true)
: '';
List<int> bytes(int field) => _fields[field] is List<int>
? _fields[field]! as List<int>
: const <int>[];
static Map<int, Object> _parse(List<int> bytes) {
final fields = <int, Object>{};
var cursor = 0;
while (cursor < bytes.length) {
final tag = _readVarint(bytes, cursor);
if (tag == null) break;
cursor = tag.next;
final field = tag.value >> 3;
switch (tag.value & 7) {
case 0:
final value = _readVarint(bytes, cursor);
if (value == null) return fields;
fields[field] = value.value;
cursor = value.next;
case 2:
final length = _readVarint(bytes, cursor);
if (length == null || length.next + length.value > bytes.length) {
return fields;
}
fields[field] = bytes.sublist(
length.next,
length.next + length.value,
);
cursor = length.next + length.value;
default:
return fields;
}
}
return fields;
}
static _ProtoValue? _readVarint(List<int> bytes, int cursor) {
var value = 0;
var shift = 0;
while (cursor < bytes.length && shift <= 63) {
final byte = bytes[cursor++];
value |= (byte & 0x7f) << shift;
if (byte & 0x80 == 0) return _ProtoValue(value, cursor);
shift += 7;
}
return null;
}
}
class _ProtoValue {
const _ProtoValue(this.value, this.next);
final int value;
final int next;
}
JinzhiUser _userFromProto(pb.UserInfo user) => JinzhiUser(
id: user.id.trim(),
phone: user.phone.trim(),
createdAt: user.createdAt.trim(),
);
+5 -3
View File
@@ -1,13 +1,15 @@
class JinzhiUser {
const JinzhiUser({
required this.id,
required this.phone,
required this.nickname,
required this.createdAt,
});
final String id;
final String phone;
final String nickname;
final DateTime createdAt;
final String createdAt;
String get nickname => '金值用户';
}
sealed class AuthState {
@@ -1,92 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../../common/network/dio_provider.dart';
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
class OneKeyLoginApi {
OneKeyLoginApi(this._dio);
final Dio _dio;
Future<OneKeyLoginReply> verify(String loginToken) async {
final bytes = utf8.encode(loginToken);
final request = Uint8List.fromList([
0x0A,
..._varint(bytes.length),
...bytes,
]);
final response = await _dio.post<List<int>>(
'/api/v1/auth/onekey/verify',
data: request,
options: protobufOptions(),
);
return _decode(Uint8List.fromList(response.data ?? const []));
}
OneKeyLoginReply _decode(Uint8List data) {
final fields = _fields(data);
final token = utf8.decode(fields[1] ?? const []);
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
return OneKeyLoginReply(
token: token,
phone: utf8.decode(user[2] ?? const []),
);
}
Map<int, List<int>> _fields(Uint8List data) {
final result = <int, List<int>>{};
var i = 0;
while (i < data.length) {
final tag = _readVarint(data, i);
i = tag.$2;
final wireType = tag.$1 & 7;
if (wireType == 0) {
i = _readVarint(data, i).$2;
continue;
}
if (wireType != 2) {
throw const FormatException('Unsupported protobuf field');
}
final length = _readVarint(data, i);
i = length.$2;
final end = i + length.$1;
if (end > data.length) {
throw const FormatException('Invalid protobuf payload');
}
result[tag.$1 >> 3] = data.sublist(i, end);
i = end;
}
return result;
}
(int, int) _readVarint(Uint8List data, int index) {
var value = 0;
var shift = 0;
while (index < data.length) {
final byte = data[index++];
value |= (byte & 0x7f) << shift;
if (byte & 0x80 == 0) return (value, index);
shift += 7;
}
throw const FormatException('Invalid protobuf varint');
}
List<int> _varint(int value) {
final result = <int>[];
while (value > 0x7f) {
result.add((value & 0x7f) | 0x80);
value >>= 7;
}
result.add(value);
return result;
}
}
class OneKeyLoginReply {
const OneKeyLoginReply({required this.token, required this.phone});
final String token;
final String phone;
}
@@ -31,7 +31,20 @@ class DeleteAccountPage extends ConsumerWidget {
),
error: (e, _) => Scaffold(
backgroundColor: cs.background,
body: Center(child: Text('加载失败:$e')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('账号状态加载失败'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () =>
ref.read(authProvider.notifier).retryBootstrap(),
child: const Text('重试'),
),
],
),
),
),
data: (authState) {
if (authState is! LoggedInAuthState) {
@@ -669,7 +682,9 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
// 键盘弹出时 sheet 跟随上移
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
@@ -715,11 +730,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'正在清除账户数据,请稍候',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
const SizedBox(height: 28),
Center(
@@ -737,11 +748,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
if (_error != null) ...[
const SizedBox(height: 10),
@@ -768,11 +775,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'验证码已发送至 ${_maskPhone(widget.phone)}',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
const SizedBox(height: 20),
_PinInput(