Files
jiu/client/lib/core/api/api_client.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

257 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../config/app_constants.dart';
import '../config/license_copy.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/license_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = _buildPublicDio();
Dio _buildPublicDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConstants.publicConnectTimeout,
receiveTimeout: AppConstants.publicReceiveTimeout,
));
dio.interceptors.add(RetryInterceptor(dio));
return dio;
}
final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
// token refresh 只改 accessTokenisLoggedIn 不变,provider 不重建,
// 避免旧 Dio 被 close 后还在执行 fetch 导致 "adapter was closed"。
ref.watch(authStateProvider.select((s) => s.isLoggedIn));
final user = ref.read(authStateProvider).user;
final client = ApiClient(
token: user?.accessToken,
refreshToken: user?.refreshToken,
onTokensRefreshed: (accessToken, refreshToken) {
ref
.read(authStateProvider.notifier)
.updateAccessToken(accessToken, refreshToken);
},
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
if (reason != null) {
ref.read(sessionEndedMessageProvider.notifier).state = reason;
}
ref.read(authStateProvider.notifier).logout();
}
},
onConnectionError: () {
ref.read(connectivityProvider.notifier).forceCheck();
},
onForbidden: (body) {
final map = body is Map ? body : const <String, dynamic>{};
final code = map['code'];
final phase = map['phase'];
// 只读角色写被拒:直接提示(无需刷新授权)。
if (code == 'READONLY_USER') {
ref.read(apiMessageProvider.notifier).state =
LicenseCopy.readonlyUserToast;
return;
}
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
if (phase == 'readonly' || phase == 'locked') {
ref.read(licenseProvider.notifier).refresh().then((_) {
final lic = ref.read(licenseProvider).valueOrNull;
ref.read(apiMessageProvider.notifier).state = lic != null
? LicenseCopy.writeBlockedToast(lic)
: '授权已过期,无法执行写操作';
});
}
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
},
);
ref.onDispose(client.dispose);
return client;
});
class ApiClient {
late final Dio _dio;
bool _disposed = false;
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
String? _refreshToken;
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
Future<String?>? _refreshing;
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
void Function(String? reason)? _onAuthFailed;
ApiClient({
String? token,
String? refreshToken,
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
void Function(dynamic body)? onForbidden,
}) {
_refreshToken = refreshToken;
_onTokensRefreshed = onTokensRefreshed;
_onAuthFailed = onAuthFailed;
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConstants.publicConnectTimeout,
receiveTimeout: AppConstants.publicReceiveTimeout,
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
if (_disposed) return handler.next(e);
// 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测
final isNetworkError = e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout;
if (isNetworkError) {
onConnectionError?.call();
return handler.next(e);
}
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
final statusCode = e.response?.statusCode ?? 0;
if (statusCode >= 500) {
reportError(
'[${e.requestOptions.method}] ${e.requestOptions.path}$statusCode',
e.stackTrace,
errorType: ErrorType.apiError,
);
}
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
if (e.response?.statusCode == 403) {
if (!_disposed) onForbidden?.call(e.response?.data);
return handler.next(e);
}
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
if (e.response?.statusCode == 401 &&
e.response?.data is Map &&
e.response?.data['code'] == 'USER_DISABLED') {
if (!_disposed) {
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
}
return handler.next(e);
}
if (e.response?.statusCode == 401 &&
(_refreshToken ?? '').isNotEmpty) {
debugPrint(
'[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
final newToken = await _refreshAccessToken();
if (newToken == null) {
return handler.next(e);
}
try {
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (retryErr) {
return handler.next(retryErr is DioException ? retryErr : e);
}
} else if (e.response?.statusCode == 401) {
debugPrint(
'[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
),
);
}
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
Future<String?> _refreshAccessToken() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': _refreshToken,
});
final data = resp.data['data'] as Map;
final newAccess = data['access_token'] as String;
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
_refreshToken = newRefresh;
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
if (!_disposed && newRefresh != null) {
_onTokensRefreshed?.call(newAccess, newRefresh);
}
return newAccess;
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) _onAuthFailed?.call(reason);
return null;
}
}
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
@visibleForTesting
Dio get dioForTest => _dio;
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
Future<List<int>> getBytes(String path) async {
final resp = await _dio.get<List<int>>(path,
options: Options(responseType: ResponseType.bytes));
return resp.data ?? [];
}
Future<Response> post(String path, {dynamic data}) =>
_dio.post(path, data: data);
Future<Response> put(String path, {dynamic data}) =>
_dio.put(path, data: data);
Future<Response> patch(String path, {dynamic data}) =>
_dio.patch(path, data: data);
Future<Response> delete(String path) => _dio.delete(path);
}
/// Unauthenticated client for login/refresh
class PublicApiClient {
static Future<Response> post(String path, {dynamic data}) =>
_publicDio.post(path, data: data);
}