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:
wangjia
2026-04-13 00:28:08 +08:00
parent 53a60d230f
commit bb4f17cf7a
44 changed files with 3384 additions and 710 deletions
+47 -40
View File
@@ -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);
},
),
);
}
/// 取消所有进行中的请求,标记实例为已废弃