Template
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
134 lines
4.8 KiB
Dart
134 lines
4.8 KiB
Dart
import 'package:dio/dio.dart';
|
||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||
import 'package:sunny_mochi/core/config/api_paths.dart';
|
||
import 'package:sunny_mochi/core/error/exception_mapper.dart';
|
||
import 'package:sunny_mochi/core/error/failures.dart';
|
||
import 'package:sunny_mochi/core/network/api_response.dart';
|
||
import 'package:sunny_mochi/core/network/dio_client.dart';
|
||
import 'package:sunny_mochi/features/auth/data/models/user_model.dart';
|
||
|
||
part 'auth_remote_datasource.g.dart';
|
||
|
||
@riverpod
|
||
AuthRemoteDatasource authRemoteDatasource(Ref ref) =>
|
||
AuthRemoteDatasource(ref.watch(dioClientProvider));
|
||
|
||
class AuthRemoteDatasource {
|
||
const AuthRemoteDatasource(this._dio);
|
||
final Dio _dio;
|
||
|
||
static const _mapper = ExceptionMapper();
|
||
|
||
/// 登录/发码端点跳过 AuthInterceptor,避免携带残留 token。
|
||
static final _skipAuthOptions = Options(extra: const {'skip_auth': true});
|
||
|
||
Future<UserModel> loginWithPhone(String phone, String code) async {
|
||
final resp = await _dio.post<Map<String, dynamic>>(
|
||
ApiPaths.authLoginSms,
|
||
data: {'account': phone, 'smsCode': code, 'loginType': 2},
|
||
options: _skipAuthOptions,
|
||
);
|
||
return _unwrapLogin(resp.data);
|
||
}
|
||
|
||
Future<UserModel> loginWithPassword(String phone, String password) async {
|
||
final resp = await _dio.post<Map<String, dynamic>>(
|
||
ApiPaths.authLoginPwd,
|
||
data: {'account': phone, 'password': password, 'loginType': 1},
|
||
options: _skipAuthOptions,
|
||
);
|
||
return _unwrapLogin(resp.data);
|
||
}
|
||
|
||
/// 返回 dev/test 环境后端回显的验证码明文(release 返回 null)。
|
||
Future<String?> sendSmsCode(String phone) async {
|
||
final resp = await _dio.post<Map<String, dynamic>>(
|
||
ApiPaths.authSmsSend,
|
||
data: {'phone': phone, 'codeType': 1},
|
||
options: _skipAuthOptions,
|
||
);
|
||
final apiResp = parseEnvelope<String?>(resp.data, (j) => j as String?);
|
||
if (!apiResp.isSuccess) {
|
||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||
}
|
||
return apiResp.retData;
|
||
}
|
||
|
||
Future<void> logout() async {
|
||
await _dio.post<void>(ApiPaths.authLogout);
|
||
}
|
||
|
||
Future<UserModel> getUserProfile() async {
|
||
final resp = await _dio.get<Map<String, dynamic>>(ApiPaths.userProfile);
|
||
final body = resp.data;
|
||
if (body == null) throw const ServerFailure(message: '服务器响应为空');
|
||
final apiResp = ApiResponse.fromJson(
|
||
body,
|
||
(json) => json as Map<String, dynamic>,
|
||
);
|
||
if (!apiResp.isSuccess) {
|
||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||
}
|
||
final data = apiResp.retData ?? const <String, dynamic>{};
|
||
return UserModel(
|
||
userId: _flexStr(data['id'] ?? data['userId']),
|
||
realName: _clean(data['realName']),
|
||
username: _clean(data['realName']),
|
||
avatar: _clean(data['avatar']),
|
||
phone: _flexStr(data['phone']),
|
||
email: _clean(data['email']),
|
||
gender: (data['sex'] as num?)?.toInt(),
|
||
employeeNo: _clean(data['workNo']),
|
||
company: _clean(data['orgName']),
|
||
department: _clean(data['deptName']),
|
||
);
|
||
}
|
||
|
||
static String? _clean(dynamic v) {
|
||
if (v == null) return null;
|
||
final s = v.toString();
|
||
if (s.isEmpty || s == '<null>' || s == 'null') return null;
|
||
return s;
|
||
}
|
||
|
||
/// 解析登录 envelope:data = {saTokenInfo: {...}, userInfo: {...}}
|
||
UserModel _unwrapLogin(Map<String, dynamic>? body) {
|
||
if (body == null) throw const ServerFailure(message: '服务器响应为空');
|
||
final apiResp = ApiResponse.fromJson(
|
||
body,
|
||
(json) => json as Map<String, dynamic>,
|
||
);
|
||
if (!apiResp.isSuccess) {
|
||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||
}
|
||
final data = apiResp.retData;
|
||
if (data == null) throw const ServerFailure(message: '登录响应缺少用户数据');
|
||
final tokenInfo = data['saTokenInfo'] as Map<String, dynamic>?;
|
||
final userInfo = data['userInfo'] as Map<String, dynamic>?;
|
||
if (tokenInfo == null || userInfo == null) {
|
||
throw const ServerFailure(message: '登录响应结构异常');
|
||
}
|
||
return UserModel(
|
||
token: tokenInfo['tokenValue'] as String?,
|
||
tokenName: tokenInfo['tokenName'] as String?,
|
||
userId: _flexStr(tokenInfo['loginId']),
|
||
realName: userInfo['realName'] as String?,
|
||
username: userInfo['realName'] as String?,
|
||
avatar: userInfo['avatar'] as String?,
|
||
phone: _flexStr(userInfo['phone']),
|
||
email: userInfo['email'] as String?,
|
||
gender: (userInfo['sex'] as num?)?.toInt(),
|
||
employeeNo: userInfo['workNo'] as String?,
|
||
company: userInfo['orgName'] as String?,
|
||
department: userInfo['deptName'] as String?,
|
||
);
|
||
}
|
||
|
||
static String? _flexStr(dynamic v) {
|
||
if (v == null) return null;
|
||
if (v is String) return v.isEmpty ? null : v;
|
||
if (v is int) return v.toString();
|
||
return null;
|
||
}
|
||
}
|