Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
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
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart';
|
||||
import 'package:sunny_mochi/core/network/response_code.dart';
|
||||
|
||||
part 'api_response.freezed.dart';
|
||||
part 'api_response.g.dart';
|
||||
|
||||
@Freezed(genericArgumentFactories: true)
|
||||
abstract class ApiResponse<T> with _$ApiResponse<T> {
|
||||
const factory ApiResponse({
|
||||
@JsonKey(name: 'code') required String retCode,
|
||||
@Default('') @JsonKey(name: 'msg') String retMsg,
|
||||
@JsonKey(name: 'data') T? retData,
|
||||
}) = _ApiResponse;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) => _$ApiResponseFromJson(json, fromJsonT);
|
||||
}
|
||||
|
||||
extension ApiResponseX<T> on ApiResponse<T> {
|
||||
bool get isSuccess => ResponseCode.isSuccess(retCode);
|
||||
|
||||
bool get isTokenExpired => ResponseCode.isUnauthorized(retCode);
|
||||
|
||||
T? unwrap() {
|
||||
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
|
||||
return retData;
|
||||
}
|
||||
|
||||
void unwrapVoid() {
|
||||
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
|
||||
}
|
||||
|
||||
T unwrapRequired([String errorMsg = '响应数据为空']) {
|
||||
final data = unwrap();
|
||||
if (data == null) throw ServerFailure(message: errorMsg);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// Datasource 层 envelope 解析入口。
|
||||
///
|
||||
/// 用法:`parseEnvelope(resp.data, (j) => MyModel.fromJson(j))?.unwrap()`
|
||||
ApiResponse<T> parseEnvelope<T>(
|
||||
Map<String, dynamic>? body,
|
||||
T Function(Object?) fromJsonT, {
|
||||
String emptyMsg = '响应为空',
|
||||
}) {
|
||||
if (body == null) throw ServerFailure(message: emptyMsg);
|
||||
return ApiResponse.fromJson(body, fromJsonT);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'api_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ApiResponse<T> {
|
||||
|
||||
@JsonKey(name: 'code') String get retCode;@JsonKey(name: 'msg') String get retMsg;@JsonKey(name: 'data') T? get retData;
|
||||
/// Create a copy of ApiResponse
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ApiResponseCopyWith<T, ApiResponse<T>> get copyWith => _$ApiResponseCopyWithImpl<T, ApiResponse<T>>(this as ApiResponse<T>, _$identity);
|
||||
|
||||
/// Serializes this ApiResponse to a JSON map.
|
||||
Map<String, dynamic> toJson(Object? Function(T) toJsonT);
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ApiResponseCopyWith<T,$Res> {
|
||||
factory $ApiResponseCopyWith(ApiResponse<T> value, $Res Function(ApiResponse<T>) _then) = _$ApiResponseCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ApiResponseCopyWithImpl<T,$Res>
|
||||
implements $ApiResponseCopyWith<T, $Res> {
|
||||
_$ApiResponseCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ApiResponse<T> _self;
|
||||
final $Res Function(ApiResponse<T>) _then;
|
||||
|
||||
/// Create a copy of ApiResponse
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
|
||||
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
|
||||
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
|
||||
as T?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ApiResponse].
|
||||
extension ApiResponsePatterns<T> on ApiResponse<T> {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ApiResponse<T> value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ApiResponse<T> value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ApiResponse<T> value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse() when $default != null:
|
||||
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse():
|
||||
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ApiResponse() when $default != null:
|
||||
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable(genericArgumentFactories: true)
|
||||
|
||||
class _ApiResponse<T> implements ApiResponse<T> {
|
||||
const _ApiResponse({@JsonKey(name: 'code') required this.retCode, @JsonKey(name: 'msg') this.retMsg = '', @JsonKey(name: 'data') this.retData});
|
||||
factory _ApiResponse.fromJson(Map<String, dynamic> json,T Function(Object?) fromJsonT) => _$ApiResponseFromJson(json,fromJsonT);
|
||||
|
||||
@override@JsonKey(name: 'code') final String retCode;
|
||||
@override@JsonKey(name: 'msg') final String retMsg;
|
||||
@override@JsonKey(name: 'data') final T? retData;
|
||||
|
||||
/// Create a copy of ApiResponse
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ApiResponseCopyWith<T, _ApiResponse<T>> get copyWith => __$ApiResponseCopyWithImpl<T, _ApiResponse<T>>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(Object? Function(T) toJsonT) {
|
||||
return _$ApiResponseToJson<T>(this, toJsonT);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ApiResponseCopyWith<T,$Res> implements $ApiResponseCopyWith<T, $Res> {
|
||||
factory _$ApiResponseCopyWith(_ApiResponse<T> value, $Res Function(_ApiResponse<T>) _then) = __$ApiResponseCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ApiResponseCopyWithImpl<T,$Res>
|
||||
implements _$ApiResponseCopyWith<T, $Res> {
|
||||
__$ApiResponseCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ApiResponse<T> _self;
|
||||
final $Res Function(_ApiResponse<T>) _then;
|
||||
|
||||
/// Create a copy of ApiResponse
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
|
||||
return _then(_ApiResponse<T>(
|
||||
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
|
||||
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
|
||||
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
|
||||
as T?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,35 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'api_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_ApiResponse<T> _$ApiResponseFromJson<T>(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object? json) fromJsonT,
|
||||
) => _ApiResponse<T>(
|
||||
retCode: json['code'] as String,
|
||||
retMsg: json['msg'] as String? ?? '',
|
||||
retData: _$nullableGenericFromJson(json['data'], fromJsonT),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ApiResponseToJson<T>(
|
||||
_ApiResponse<T> instance,
|
||||
Object? Function(T value) toJsonT,
|
||||
) => <String, dynamic>{
|
||||
'code': instance.retCode,
|
||||
'msg': instance.retMsg,
|
||||
'data': _$nullableGenericToJson(instance.retData, toJsonT),
|
||||
};
|
||||
|
||||
T? _$nullableGenericFromJson<T>(
|
||||
Object? input,
|
||||
T Function(Object? json) fromJson,
|
||||
) => input == null ? null : fromJson(input);
|
||||
|
||||
Object? _$nullableGenericToJson<T>(
|
||||
T? input,
|
||||
Object? Function(T value) toJson,
|
||||
) => input == null ? null : toJson(input);
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/config/api_config.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/auth_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/auth_logout_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/cert_pinning_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/error_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/log_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/retry_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart';
|
||||
import 'package:sunny_mochi/core/network/mock/dio_mock_adapter.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'dio_client.g.dart';
|
||||
|
||||
/// 完整 7 拦截器栈:
|
||||
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
|
||||
///
|
||||
/// 顺序敏感:
|
||||
/// - CertPinning 最早(在请求出去前校验)
|
||||
/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发
|
||||
/// - Retry 在网络/5xx 错误时退避重试
|
||||
/// - Error 把所有 DioException 映射为应用层 Failure
|
||||
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
|
||||
/// - Log 最后,记录最终结果(Release 包 noop)
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dioClient(Ref ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiConfig.baseUrl,
|
||||
connectTimeout: ApiConfig.connectTimeout,
|
||||
receiveTimeout: ApiConfig.receiveTimeout,
|
||||
sendTimeout: ApiConfig.sendTimeout,
|
||||
headers: const {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
dio.interceptors.addAll([
|
||||
CertPinningInterceptor(),
|
||||
AuthInterceptor(ref),
|
||||
TokenRefreshInterceptor(ref, dio),
|
||||
RetryInterceptor(dio),
|
||||
ErrorInterceptor(),
|
||||
AuthLogoutInterceptor(ref),
|
||||
buildNetworkLogInterceptor(),
|
||||
]);
|
||||
|
||||
if (Env.useMock) {
|
||||
dio.httpClientAdapter = buildDefaultMockAdapter();
|
||||
}
|
||||
|
||||
return dio;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'dio_client.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// 完整 7 拦截器栈:
|
||||
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
|
||||
///
|
||||
/// 顺序敏感:
|
||||
/// - CertPinning 最早(在请求出去前校验)
|
||||
/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发
|
||||
/// - Retry 在网络/5xx 错误时退避重试
|
||||
/// - Error 把所有 DioException 映射为应用层 Failure
|
||||
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
|
||||
/// - Log 最后,记录最终结果(Release 包 noop)
|
||||
|
||||
@ProviderFor(dioClient)
|
||||
final dioClientProvider = DioClientProvider._();
|
||||
|
||||
/// 完整 7 拦截器栈:
|
||||
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
|
||||
///
|
||||
/// 顺序敏感:
|
||||
/// - CertPinning 最早(在请求出去前校验)
|
||||
/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发
|
||||
/// - Retry 在网络/5xx 错误时退避重试
|
||||
/// - Error 把所有 DioException 映射为应用层 Failure
|
||||
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
|
||||
/// - Log 最后,记录最终结果(Release 包 noop)
|
||||
|
||||
final class DioClientProvider extends $FunctionalProvider<Dio, Dio, Dio>
|
||||
with $Provider<Dio> {
|
||||
/// 完整 7 拦截器栈:
|
||||
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
|
||||
///
|
||||
/// 顺序敏感:
|
||||
/// - CertPinning 最早(在请求出去前校验)
|
||||
/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发
|
||||
/// - Retry 在网络/5xx 错误时退避重试
|
||||
/// - Error 把所有 DioException 映射为应用层 Failure
|
||||
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
|
||||
/// - Log 最后,记录最终结果(Release 包 noop)
|
||||
DioClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'dioClientProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$dioClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Dio> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Dio create(Ref ref) {
|
||||
return dioClient(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Dio value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Dio>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$dioClientHash() => r'f740b06528c313c24f8288686585bdf379411fa2';
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart'
|
||||
show TokenRefreshInterceptor;
|
||||
import 'package:sunny_mochi/core/storage/secure_storage.dart'
|
||||
show secureStorageProvider;
|
||||
|
||||
/// 自动注入 sa-token 鉴权 header。
|
||||
///
|
||||
/// **后端是 sa-token 框架** — header 名是动态的(登录响应 `saTokenInfo.tokenName` 返回,
|
||||
/// 如 `satoken`),不是固定的 `Authorization: Bearer xxx`。
|
||||
/// Token 失效(401)的处理由 [TokenRefreshInterceptor] 接管,本拦截器只管注入。
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// 兜底 header 名 — 当 SecureStorage 里没有 tokenName 时使用 sa-token 框架默认值。
|
||||
static const String _fallbackTokenName = 'satoken';
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final skipAuth = options.extra['skip_auth'] == true;
|
||||
if (skipAuth) return handler.next(options);
|
||||
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
final token = await storage.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
final tokenName = await storage.getTokenName() ?? _fallbackTokenName;
|
||||
options.headers[tokenName] = token;
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:sunny_mochi/core/storage/secure_storage.dart'
|
||||
show secureStorageProvider;
|
||||
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart';
|
||||
|
||||
/// 全局 AuthFailure → markLoggedOut 副作用拦截器。
|
||||
///
|
||||
/// 链路:
|
||||
/// 1. ErrorInterceptor 已经把 [DioException] 映射为 [Failure] 写回 `e.error`
|
||||
/// 2. 本拦截器(必须放在 ErrorInterceptor **之后**)检查 `e.error`
|
||||
/// 3. 命中 [AuthFailure] 的 unauthorized / refreshFailed 时:
|
||||
/// - 清空 SecureStorage
|
||||
/// - markLoggedOut → router refreshListenable 触发 → 自动跳 /login
|
||||
/// 4. forbidden 不清登录态,仅由 UI 处理
|
||||
class AuthLogoutInterceptor extends Interceptor {
|
||||
AuthLogoutInterceptor(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final failure = err.error;
|
||||
if (failure is AuthFailure && _shouldLogout(failure.kind)) {
|
||||
appTalker.warning(
|
||||
'[AuthLogout] AuthFailure(${failure.kind.name}) → markLoggedOut + clearAll',
|
||||
);
|
||||
try {
|
||||
await _ref.read(secureStorageProvider).clearAll();
|
||||
} on Object catch (e, st) {
|
||||
appTalker.warning('[AuthLogout] clearAll 失败', e, st);
|
||||
}
|
||||
_ref.read(authStatusProvider).markLoggedOut();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
bool _shouldLogout(AuthFailureKind kind) =>
|
||||
kind == AuthFailureKind.unauthorized ||
|
||||
kind == AuthFailureKind.refreshFailed;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
|
||||
/// TLS 证书绑定(Pinning)拦截器。
|
||||
///
|
||||
/// 当前为**占位实现**:[Env.pinnedFingerprints] 为空时跳过校验,
|
||||
/// 非空时由 HttpClientAdapter 层做真实 SHA-256 比对。
|
||||
/// 生产指纹在环境就绪后替换 Dio.httpClientAdapter 接入。
|
||||
class CertPinningInterceptor extends Interceptor {
|
||||
CertPinningInterceptor();
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (Env.pinnedFingerprints.isEmpty) {
|
||||
return handler.next(options);
|
||||
}
|
||||
appTalker.verbose(
|
||||
'[CertPinning] ${options.uri.host} → 准备校验(${Env.pinnedFingerprints.length} 指纹)',
|
||||
);
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/error/exception_mapper.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart' show Failure;
|
||||
|
||||
/// 把 [DioException] 映射为应用层 [Failure],写回 `error.error` 字段。
|
||||
///
|
||||
/// 后续上层只需 catch DioException 然后读 `e.error as Failure`,
|
||||
/// 或者在 Repository 层 catch DioException 后调用 [ExceptionMapper.fromDio]。
|
||||
class ErrorInterceptor extends Interceptor {
|
||||
ErrorInterceptor({ExceptionMapper? mapper})
|
||||
: _mapper = mapper ?? const ExceptionMapper();
|
||||
|
||||
final ExceptionMapper _mapper;
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final failure = _mapper.fromDio(err);
|
||||
handler.next(
|
||||
err.copyWith(
|
||||
error: failure,
|
||||
message: failure.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
|
||||
/// 网络请求日志:仅在 [Env.enableDevPanel](debug 或内测包)启用,
|
||||
/// Release 包不记录请求/响应 body(含敏感数据)。
|
||||
Interceptor buildNetworkLogInterceptor() {
|
||||
if (!Env.enableDevPanel) {
|
||||
return InterceptorsWrapper();
|
||||
}
|
||||
return TalkerDioLogger(talker: appTalker);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
|
||||
/// 408/429/500/502/503/504 指数退避重试,最多 [maxRetries] 次。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
RetryInterceptor(this._dio, {this.maxRetries = 3});
|
||||
|
||||
final Dio _dio;
|
||||
final int maxRetries;
|
||||
|
||||
static const _retryStatuses = {408, 429, 500, 502, 503, 504};
|
||||
static const Set<DioExceptionType> _retryDioTypes = {
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.connectionError,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final status = err.response?.statusCode;
|
||||
final attempt = (err.requestOptions.extra['_retry_attempt'] as int?) ?? 0;
|
||||
|
||||
final shouldRetry =
|
||||
(status != null && _retryStatuses.contains(status)) ||
|
||||
_retryDioTypes.contains(err.type);
|
||||
|
||||
if (!shouldRetry || attempt >= maxRetries) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 指数退避:200ms → 400ms → 800ms
|
||||
final delayMs = 200 * (1 << attempt);
|
||||
appTalker.info(
|
||||
'[Retry] ${err.requestOptions.uri.path} 第 ${attempt + 1}/$maxRetries 次重试(${delayMs}ms 后)',
|
||||
);
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
|
||||
try {
|
||||
final response = await _dio.fetch<dynamic>(
|
||||
err.requestOptions..extra['_retry_attempt'] = attempt + 1,
|
||||
);
|
||||
return handler.resolve(response);
|
||||
} on DioException catch (e) {
|
||||
return handler.next(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sunny_mochi/core/config/api_paths.dart';
|
||||
import 'package:sunny_mochi/core/network/response_code.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:sunny_mochi/core/storage/secure_storage.dart'
|
||||
show secureStorageProvider;
|
||||
|
||||
/// 401 时尝试 refresh 一次 → 重放原请求;refresh 也失败才清空 token。
|
||||
///
|
||||
/// **互斥实现**:用 [Completer] 显式表达互斥语义,避免并发 401 重复消耗 RefreshToken:
|
||||
/// - 第一个 401 进入 → new Completer + 把 future 缓存在 [_refreshing]
|
||||
/// - 后续并发 401 → await 同一个 [_refreshing] 的 future
|
||||
/// - refresh 完成后 [_refreshing] 清空,新一轮 401 才会再次触发
|
||||
///
|
||||
/// **业务码分类**:
|
||||
/// - status==401 + body.retCode ∈ token 类码 → refresh + 重放
|
||||
/// - status==401 + body.retCode 是其他业务码(如权限不足)→ 透传,不消耗 RefreshToken
|
||||
/// - status==401 + 无 body 或无 retCode → 透传,不消耗 RefreshToken
|
||||
class TokenRefreshInterceptor extends Interceptor {
|
||||
TokenRefreshInterceptor(this._ref, this._dio);
|
||||
|
||||
final Ref _ref;
|
||||
final Dio _dio;
|
||||
|
||||
Completer<bool>? _refreshing;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final status = err.response?.statusCode;
|
||||
final retried = err.requestOptions.extra['_token_refreshed'] == true;
|
||||
final isAuthEndpoint = err.requestOptions.extra['skip_auth'] == true;
|
||||
|
||||
if (status != 401 || retried || isAuthEndpoint) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final retCode = _extractRetCode(err.response?.data);
|
||||
if (!ResponseCode.isTokenRefreshable(retCode)) {
|
||||
err.requestOptions.extra['_auth_not_refreshable'] = true;
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
final refreshToken = await storage.getRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
await storage.clearAll();
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final pending = _refreshing;
|
||||
final bool ok;
|
||||
if (pending != null) {
|
||||
ok = await pending.future;
|
||||
} else {
|
||||
final completer = Completer<bool>();
|
||||
_refreshing = completer;
|
||||
bool result = false;
|
||||
try {
|
||||
result = await _runRefresh(refreshToken);
|
||||
} finally {
|
||||
// 先清空 _refreshing,再 complete:
|
||||
// 确保新到来的 401 能进入下一轮 refresh,而当前 waiters 仍通过
|
||||
// 已持有的 completer 引用得到结果(不受 _refreshing = null 影响)。
|
||||
_refreshing = null;
|
||||
completer.complete(result);
|
||||
}
|
||||
ok = result;
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
err.requestOptions.extra['_auth_refresh_failed'] = true;
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
try {
|
||||
final newToken = await storage.getToken();
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
final tokenName = await storage.getTokenName() ?? 'satoken';
|
||||
err.requestOptions.headers[tokenName] = newToken;
|
||||
}
|
||||
final retriedResp = await _dio.fetch<dynamic>(
|
||||
err.requestOptions..extra['_token_refreshed'] = true,
|
||||
);
|
||||
return handler.resolve(retriedResp);
|
||||
} on DioException catch (e) {
|
||||
return handler.next(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _runRefresh(String refreshToken) async {
|
||||
try {
|
||||
return await _doRefresh(refreshToken);
|
||||
} on Object catch (e, st) {
|
||||
appTalker.warning('[TokenRefresh] 刷新失败:$e', e, st);
|
||||
await _ref.read(secureStorageProvider).clearAll();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _doRefresh(String refreshToken) async {
|
||||
final resp = await _dio.post<Map<String, dynamic>>(
|
||||
ApiPaths.authRefresh,
|
||||
data: {'refresh_token': refreshToken},
|
||||
options: Options(extra: {'skip_auth': true}),
|
||||
);
|
||||
final body = resp.data;
|
||||
final retCode = body?['code'] as String? ?? body?['retCode'] as String?;
|
||||
if (retCode != '00000') return false;
|
||||
|
||||
final retData =
|
||||
(body?['data'] ?? body?['retData']) as Map<String, dynamic>?;
|
||||
final access = retData?['access_token'] as String?;
|
||||
final refresh = retData?['refresh_token'] as String?;
|
||||
if (access == null || access.isEmpty) return false;
|
||||
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
await storage.setToken(access);
|
||||
if (refresh != null && refresh.isNotEmpty) {
|
||||
await storage.setRefreshToken(refresh);
|
||||
}
|
||||
// tokenName 可能随 refresh 响应更新(对齐登录路径 auth_repository_impl._persistUser)
|
||||
final newTokenName = retData?['token_name'] as String?
|
||||
?? retData?['tokenName'] as String?;
|
||||
if (newTokenName != null && newTokenName.isNotEmpty) {
|
||||
await storage.setTokenName(newTokenName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String? _extractRetCode(Object? body) {
|
||||
if (body is! Map) return null;
|
||||
final raw = body['retCode'] ?? body['code'] ?? body['errorCode'];
|
||||
return raw is String ? raw : raw?.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sunny_mochi/core/config/api_paths.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart' show Env;
|
||||
import 'package:sunny_mochi/core/network/mock/mock_response_loader.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
|
||||
/// 把 Dio 请求拦截,根据 path + method 返回 fixtures 中的预设响应。
|
||||
///
|
||||
/// 启用方式:在 dio_client.dart 中检查 [Env.useMock]:
|
||||
/// ```dart
|
||||
/// if (Env.useMock) {
|
||||
/// dio.httpClientAdapter = buildDefaultMockAdapter();
|
||||
/// }
|
||||
/// ```
|
||||
class DioMockAdapter implements HttpClientAdapter {
|
||||
DioMockAdapter({this.delay = const Duration(milliseconds: 200)});
|
||||
|
||||
final Map<String, String> _routes = {};
|
||||
final Duration delay;
|
||||
bool _closed = false;
|
||||
|
||||
/// 注册一条 mock 路由:method+path → fixtures 文件名(不含 .json 后缀)。
|
||||
void register(String method, String path, String fixtureName) {
|
||||
_routes['${method.toUpperCase()} $path'] = fixtureName;
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {
|
||||
_closed = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (_closed) {
|
||||
throw DioException(requestOptions: options, message: 'adapter closed');
|
||||
}
|
||||
|
||||
await Future<void>.delayed(delay);
|
||||
|
||||
final key = '${options.method} ${options.path}';
|
||||
final exact = _routes[key];
|
||||
var matched = exact;
|
||||
if (matched == null) {
|
||||
for (final entry in _routes.entries) {
|
||||
if (key.startsWith(entry.key)) {
|
||||
matched = entry.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched == null) {
|
||||
appTalker.warning('[Mock] 未注册 fixture:$key — 返回空 envelope');
|
||||
final body = jsonEncode({
|
||||
'code': '00000',
|
||||
'msg': 'mock-empty',
|
||||
'data': null,
|
||||
});
|
||||
return ResponseBody.fromString(
|
||||
body,
|
||||
200,
|
||||
headers: {'content-type': ['application/json']},
|
||||
);
|
||||
}
|
||||
|
||||
appTalker.verbose('[Mock] $key → fixtures/$matched.json');
|
||||
final json = await MockResponseLoader.load(matched);
|
||||
final body = jsonEncode(json);
|
||||
return ResponseBody.fromString(
|
||||
body,
|
||||
200,
|
||||
headers: {'content-type': ['application/json']},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认的全局 mock 路由表。
|
||||
// TODO: 按项目实际 API 路径扩展此路由表
|
||||
DioMockAdapter buildDefaultMockAdapter() {
|
||||
return DioMockAdapter()
|
||||
// === Auth ===
|
||||
..register('POST', ApiPaths.authLoginSms, 'auth/login_success')
|
||||
..register('POST', ApiPaths.authLoginPwd, 'auth/login_success')
|
||||
..register('POST', ApiPaths.authSmsSend, 'auth/send_sms_code_success')
|
||||
..register('POST', ApiPaths.authRefresh, 'auth/refresh_success')
|
||||
..register('POST', ApiPaths.crashReport, 'common/generic_success')
|
||||
// === User / Mine ===
|
||||
..register('GET', ApiPaths.userProfile, 'mine/profile')
|
||||
..register('POST', ApiPaths.userChangePassword, 'common/generic_success')
|
||||
..register('POST', ApiPaths.userAvatarUpload, 'mine/avatar_upload');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
/// 从 assets/fixtures/ 加载 JSON 响应。
|
||||
///
|
||||
/// 使用:
|
||||
/// ```dart
|
||||
/// final body = await MockResponseLoader.load('auth/login_success');
|
||||
/// ```
|
||||
abstract class MockResponseLoader {
|
||||
static final Map<String, Map<String, dynamic>> _cache = {};
|
||||
|
||||
static Future<Map<String, dynamic>> load(String name) async {
|
||||
if (_cache.containsKey(name)) return _cache[name]!;
|
||||
final raw = await rootBundle.loadString('assets/fixtures/$name.json');
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_cache[name] = json;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
sealed class NetworkException implements Exception {
|
||||
const NetworkException(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
class NoNetworkException extends NetworkException {
|
||||
const NoNetworkException() : super('网络不可用,请检查网络连接');
|
||||
}
|
||||
|
||||
class TimeoutException extends NetworkException {
|
||||
const TimeoutException() : super('请求超时,请稍后重试');
|
||||
}
|
||||
|
||||
class ServerException extends NetworkException {
|
||||
const ServerException([super.msg = '服务器异常,请稍后重试']);
|
||||
}
|
||||
|
||||
class UnauthorizedException extends NetworkException {
|
||||
const UnauthorizedException() : super('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
class ApiException extends NetworkException {
|
||||
const ApiException(super.message, this.code);
|
||||
final String code;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 服务端业务错误码常量。
|
||||
///
|
||||
/// envelope 结构:`{retCode: String, retMsg: String, retData: T?}`(key map: code/msg/data)
|
||||
/// 错误码格式:`A04XX` = 客户端错 / `A05XX` = 服务端错 / `99999` = 兜底 / `00000` = 成功
|
||||
abstract class ResponseCode {
|
||||
/// 成功
|
||||
static const String success = '00000';
|
||||
|
||||
/// 兜底请求失败
|
||||
static const String generic = '99999';
|
||||
|
||||
/// 服务端 500 类
|
||||
static const String serverError = 'A0500';
|
||||
static const String systemError = 'A0501';
|
||||
|
||||
/// 客户端 400 类
|
||||
static const String paramError = 'A0400';
|
||||
static const String paramMissing = 'A0402';
|
||||
static const String resourceNotFound = 'A0404';
|
||||
|
||||
/// 鉴权过期 → 跳登录
|
||||
static const String unauthorized = 'A0401';
|
||||
|
||||
/// Token 类业务码(占位 — 待后端确认实际值后改这里一处)。
|
||||
// TODO: 后端确认后更新 tokenExpired / tokenInvalid 实际字符串值
|
||||
static const String tokenExpired = 'TOKEN_EXPIRED';
|
||||
static const String tokenInvalid = 'TOKEN_INVALID';
|
||||
|
||||
/// 仅 token 类业务码才允许 TokenRefreshInterceptor 触发 refresh。
|
||||
static bool isTokenRefreshable(String? code) =>
|
||||
code == unauthorized || code == tokenExpired || code == tokenInvalid;
|
||||
|
||||
static bool isSuccess(String? code) => code == success;
|
||||
|
||||
static bool isUnauthorized(String? code) => code == unauthorized;
|
||||
|
||||
/// 用户友好的错误提示。
|
||||
static String friendlyMessage(String? code, String fallback) {
|
||||
return switch (code) {
|
||||
success => '',
|
||||
unauthorized => '登录已过期,请重新登录',
|
||||
paramError => '请求参数有误,请稍候重试',
|
||||
paramMissing => '请求参数不完整,请稍候重试',
|
||||
resourceNotFound => '请求的资源不存在',
|
||||
serverError || systemError => '服务器开小差啦,请稍候重试',
|
||||
generic => '请求失败',
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user