feat: 自动更新、系统设置、安全修复
后端: - 新增 GET /version 版本检查端点(version.go + version.yaml) - 新增 GET /license/info 接口,返回门店授权信息 - 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁 - 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE - 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY - 修复 Product Update 全字段覆盖:改用 selective Updates() - 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由) - version.go 配置缺失时返回 500 而非静默降级 前端: - 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗 - 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈 - 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据 - 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发) - 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入 - update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃 - dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
const _baseUrl = 'http://localhost:8080/api/v1';
|
||||
import '../config/app_config.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@@ -31,6 +31,9 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
onConnectionError: () {
|
||||
ref.read(connectivityProvider.notifier).forceCheck();
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
@@ -45,9 +48,10 @@ class ApiClient {
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function()? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
@@ -56,43 +60,46 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 401 auto-refresh 拦截器
|
||||
if (refreshToken != null) {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (DioException e, ErrorInterceptorHandler handler) async {
|
||||
// 如果这个 client 已被替换,忽略所有回调
|
||||
if (_disposed) {
|
||||
return handler.next(e);
|
||||
}
|
||||
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken =
|
||||
resp.data['data']['access_token'] as String;
|
||||
// 原地更新 Dio 默认 headers,后续所有请求生效
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
// 用新 token 重试原请求(此时 _dio 仍存活,不会 adapter closed)
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
}
|
||||
// 网络错误 + 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken = resp.data['data']['access_token'] as String;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
|
||||
Reference in New Issue
Block a user