diff --git a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill index a26621c..d1c0fb8 100644 Binary files a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill and b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill differ diff --git a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache index 2413608..83d62cc 100644 --- a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache +++ b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache @@ -1 +1 @@ -{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"edec2d654196d82837fe30749e6dbe4a"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"c567bff329b871aece50234d77fae5fc"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"1f80d3fe423ab4d02b5dd6e69b991eef"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","hash":"f78c405bcece3968277b212042da9ed6"},{"path":"d:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"edec2d654196d82837fe30749e6dbe4a"}]} \ No newline at end of file +{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"93b7e7d77cba4a35eb6bc729e36e1e33"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"85d5816be16d276f9cdb46bddb1ac44b"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"07e07657ecee3d22aec9d4e7fb4674cf"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","hash":"f78c405bcece3968277b212042da9ed6"},{"path":"d:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"93b7e7d77cba4a35eb6bc729e36e1e33"}]} \ No newline at end of file diff --git a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json index 538182c..9e78c3f 100644 --- a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json +++ b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json @@ -1 +1 @@ -{"build_start":"2026-03-29T14:48:56.489920","build_end":"2026-03-29T14:49:01.118318","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]} \ No newline at end of file +{"build_start":"2026-03-29T23:59:28.762496","build_end":"2026-03-29T23:59:31.683415","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]} \ No newline at end of file diff --git a/flutter_monisuo/lib/core/network/api_response.dart b/flutter_monisuo/lib/core/network/api_response.dart index 3a50d1e..e2ab927 100644 --- a/flutter_monisuo/lib/core/network/api_response.dart +++ b/flutter_monisuo/lib/core/network/api_response.dart @@ -2,6 +2,7 @@ class ResponseCode { static const String success = '0000'; static const String unauthorized = '0002'; + static const String kycRequired = 'KYC_REQUIRED'; } /// API 响应模型 @@ -71,4 +72,5 @@ class ApiResponse { bool get isSuccess => success; bool get isUnauthorized => code == ResponseCode.unauthorized; + bool get isKycRequired => code == ResponseCode.kycRequired; } diff --git a/flutter_monisuo/lib/core/network/dio_client.dart b/flutter_monisuo/lib/core/network/dio_client.dart index 283a0fc..fad262a 100644 --- a/flutter_monisuo/lib/core/network/dio_client.dart +++ b/flutter_monisuo/lib/core/network/dio_client.dart @@ -68,6 +68,24 @@ class DioClient { } } + /// Multipart 文件上传 + Future> upload( + String path, { + required FormData formData, + T Function(dynamic)? fromJson, + }) async { + try { + final response = await _dio.post( + path, + data: formData, + options: Options(contentType: 'multipart/form-data'), + ); + return _handleResponse(response, fromJson); + } on DioException catch (e) { + return _handleError(e); + } + } + ApiResponse _handleResponse( Response response, T Function(dynamic)? fromJson, diff --git a/flutter_monisuo/lib/data/services/user_service.dart b/flutter_monisuo/lib/data/services/user_service.dart index 607328c..873f167 100644 --- a/flutter_monisuo/lib/data/services/user_service.dart +++ b/flutter_monisuo/lib/data/services/user_service.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; +import 'package:dio/dio.dart'; import '../../core/constants/api_endpoints.dart'; import '../../core/network/api_response.dart'; import '../../core/network/dio_client.dart'; @@ -39,14 +41,19 @@ class UserService { ); } - /// 上传 KYC 资料 + /// 上传 KYC 资料(身份证正反面图片字节) + /// 使用 fromBytes 以兼容 Web 和移动端 Future> uploadKyc( - String idCardFront, - String idCardBack, + Uint8List frontBytes, + Uint8List backBytes, ) async { - return _client.post( + final formData = FormData.fromMap({ + 'front': MultipartFile.fromBytes(frontBytes, filename: 'front.jpg'), + 'back': MultipartFile.fromBytes(backBytes, filename: 'back.jpg'), + }); + return _client.upload( ApiEndpoints.kyc, - data: {'idCardFront': idCardFront, 'idCardBack': idCardBack}, + formData: formData, ); } diff --git a/flutter_monisuo/lib/providers/auth_provider.dart b/flutter_monisuo/lib/providers/auth_provider.dart index 23ec739..efe028f 100644 --- a/flutter_monisuo/lib/providers/auth_provider.dart +++ b/flutter_monisuo/lib/providers/auth_provider.dart @@ -1,3 +1,4 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../core/network/api_response.dart'; import '../core/network/dio_client.dart'; @@ -134,6 +135,21 @@ class AuthProvider extends ChangeNotifier { } } + /// 提交KYC实名认证(真实图片上传) + Future> submitKyc( + Uint8List frontBytes, Uint8List backBytes) async { + try { + final response = + await _userService.uploadKyc(frontBytes, backBytes); + if (response.success) { + await refreshUserInfo(); + } + return response; + } catch (e) { + return ApiResponse.fail('KYC提交失败: $e'); + } + } + void _setLoading(bool value) { _isLoading = value; notifyListeners(); diff --git a/flutter_monisuo/lib/ui/pages/asset/asset_page.dart b/flutter_monisuo/lib/ui/pages/asset/asset_page.dart index 9454f8b..7ac50d5 100644 --- a/flutter_monisuo/lib/ui/pages/asset/asset_page.dart +++ b/flutter_monisuo/lib/ui/pages/asset/asset_page.dart @@ -6,11 +6,13 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/theme/app_color_scheme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../providers/asset_provider.dart'; +import '../../../providers/auth_provider.dart'; import '../../shared/ui_constants.dart'; import '../../components/glass_panel.dart'; import '../../components/neon_glow.dart'; import '../orders/fund_orders_page.dart'; import 'transfer_page.dart'; +import '../mine/kyc_page.dart'; /// 资产页面 - Material Design 3 风格 class AssetPage extends StatefulWidget { @@ -897,6 +899,13 @@ class _WalletAddressCard extends StatelessWidget { } void _showWithdrawDialog(BuildContext context, String? balance) { + // KYC校验:未完成实名认证则引导去认证 + final auth = context.read(); + if (auth.user?.kycStatus != 2) { + _showKycRequiredDialog(context); + return; + } + final amountController = TextEditingController(); final addressController = TextEditingController(); final contactController = TextEditingController(); @@ -1109,23 +1118,17 @@ void _showResultDialog(BuildContext context, String title, String? message) { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - title, - style: GoogleFonts.spaceGrotesk( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), + Text(title, + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + )), if (message != null) ...[ SizedBox(height: AppSpacing.sm), - Text( - message, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), + Text(message, + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center), ], SizedBox(height: AppSpacing.lg), SizedBox( @@ -1144,3 +1147,85 @@ void _showResultDialog(BuildContext context, String title, String? message) { ), ); } + +void _showKycRequiredDialog(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + showShadDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + child: GlassPanel( + borderRadius: BorderRadius.circular(AppRadius.xxl), + padding: EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColorScheme.warning.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.shieldAlert, + color: AppColorScheme.warning, + size: 32, + ), + ), + SizedBox(height: AppSpacing.md), + Text( + '需要实名认证', + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + SizedBox(height: AppSpacing.sm), + Text( + '提现前请先完成实名认证,保障账户安全', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: NeonButton( + text: '取消', + type: NeonButtonType.outline, + onPressed: () => Navigator.of(ctx).pop(), + height: 44, + showGlow: false, + ), + ), + SizedBox(width: AppSpacing.sm), + Expanded( + child: NeonButton( + text: '去认证', + type: NeonButtonType.primary, + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const KycPage(returnToWithdraw: true), + ), + ); + }, + height: 44, + showGlow: true, + ), + ), + ], + ), + ], + ), + ), + ), + ); +} diff --git a/flutter_monisuo/lib/ui/pages/home/home_page.dart b/flutter_monisuo/lib/ui/pages/home/home_page.dart index 84aff35..41fc23a 100644 --- a/flutter_monisuo/lib/ui/pages/home/home_page.dart +++ b/flutter_monisuo/lib/ui/pages/home/home_page.dart @@ -88,11 +88,11 @@ class _HomePageState extends State ), SizedBox(height: AppSpacing.md), // 新人福利卡片 - if (!_bonusClaimed) - _BonusCard( - onClaim: _claimBonus, - isLoading: _bonusLoading, - ), + _BonusCard( + isClaimed: _bonusClaimed, + onClaim: _claimBonus, + isLoading: _bonusLoading, + ), SizedBox(height: AppSpacing.lg), // 持仓 _HoldingsSection(holdings: provider.holdings), @@ -963,105 +963,118 @@ class _AssetCardState extends State<_AssetCard> { /// 新人福利卡片 class _BonusCard extends StatelessWidget { + final bool isClaimed; final VoidCallback onClaim; final bool isLoading; - const _BonusCard({required this.onClaim, required this.isLoading}); + const _BonusCard({required this.isClaimed, required this.onClaim, required this.isLoading}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - return GestureDetector( - onTap: isLoading ? null : onClaim, - child: Container( - width: double.infinity, - padding: EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - colorScheme.primary.withOpacity(0.15), - colorScheme.secondary.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + return Opacity( + opacity: isClaimed ? 0.6 : 1.0, + child: GestureDetector( + onTap: isClaimed || isLoading ? null : onClaim, + child: Container( + width: double.infinity, + padding: EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.primary.withOpacity(0.15), + colorScheme.secondary.withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: colorScheme.primary.withOpacity(0.2)), ), - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: colorScheme.primary.withOpacity(0.2)), - ), - child: Row( - children: [ - // 左侧图标 - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.lg), + child: Row( + children: [ + // 左侧图标 + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isClaimed + ? colorScheme.onSurfaceVariant.withOpacity(0.1) + : colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Icon( + isClaimed ? LucideIcons.check : LucideIcons.gift, + color: isClaimed ? AppColorScheme.up : colorScheme.primary, + size: 24, + ), ), - child: Icon( - LucideIcons.gift, - color: colorScheme.primary, - size: 24, - ), - ), - SizedBox(width: AppSpacing.md), - // 中间文字 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '新人福利', - style: GoogleFonts.spaceGrotesk( - fontSize: 16, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - SizedBox(height: AppSpacing.xs), - Text( - '领取 50 USDT 体验金,开始您的交易之旅', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // 右侧按钮 - Container( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - gradient: isDark - ? AppColorScheme.darkCtaGradient - : AppColorScheme.lightCtaGradient, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: isLoading - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: isDark ? colorScheme.background : Colors.white, + SizedBox(width: AppSpacing.md), + // 中间文字 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '新人福利', + style: GoogleFonts.spaceGrotesk( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), - ) - : Text( - '立即领取', + ), + SizedBox(height: AppSpacing.xs), + Text( + isClaimed ? '50 USDT 体验金已到账' : '领取 50 USDT 体验金,开始您的交易之旅', style: TextStyle( - color: isDark ? colorScheme.background : Colors.white, - fontSize: 13, - fontWeight: FontWeight.w700, + fontSize: 12, + color: colorScheme.onSurfaceVariant, ), ), - ), - ], + ], + ), + ), + // 右侧按钮 + Container( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: isClaimed + ? colorScheme.onSurfaceVariant.withOpacity(0.15) + : null, + gradient: isClaimed + ? null + : (isDark + ? AppColorScheme.darkCtaGradient + : AppColorScheme.lightCtaGradient), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: isLoading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? colorScheme.background : Colors.white, + ), + ) + : Text( + isClaimed ? '已领取' : '立即领取', + style: TextStyle( + color: isClaimed + ? colorScheme.onSurfaceVariant + : (isDark ? colorScheme.background : Colors.white), + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), ), ), ); diff --git a/flutter_monisuo/lib/ui/pages/market/market_page.dart b/flutter_monisuo/lib/ui/pages/market/market_page.dart index 1869d8d..7b26d60 100644 --- a/flutter_monisuo/lib/ui/pages/market/market_page.dart +++ b/flutter_monisuo/lib/ui/pages/market/market_page.dart @@ -170,9 +170,7 @@ class _FeaturedCard extends StatelessWidget { ? AppColorScheme.getUpBackgroundColor(isDark) : colorScheme.error.withOpacity(0.1); - return GestureDetector( - onTap: () => _navigateToTrade(context), - child: GlassPanel( + return GlassPanel( padding: EdgeInsets.all(AppSpacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -244,14 +242,8 @@ class _FeaturedCard extends StatelessWidget { ), ], ), - ), ); } - - void _navigateToTrade(BuildContext context) { - final mainState = context.findAncestorStateOfType(); - mainState?.switchToTrade(coin.code); - } } /// 下半区列表项 diff --git a/flutter_monisuo/lib/ui/pages/mine/mine_page.dart b/flutter_monisuo/lib/ui/pages/mine/mine_page.dart index 10ffe49..b48ee77 100644 --- a/flutter_monisuo/lib/ui/pages/mine/mine_page.dart +++ b/flutter_monisuo/lib/ui/pages/mine/mine_page.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/theme/app_color_scheme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../providers/auth_provider.dart'; +import 'kyc_page.dart'; import '../../../providers/theme_provider.dart'; import '../auth/login_page.dart'; import '../../components/glass_panel.dart'; @@ -54,7 +55,11 @@ class _MinePageState extends State with AutomaticKeepAliveClientMixin children: [ _UserCard(user: auth.user), SizedBox(height: AppSpacing.md), - _MenuList(onShowComingSoon: _showComingSoon, onShowAbout: _showAboutDialog), + _MenuList( + onShowComingSoon: _showComingSoon, + onShowAbout: _showAboutDialog, + kycStatus: auth.user?.kycStatus ?? 0, + ), SizedBox(height: AppSpacing.xl), _LogoutButton(onLogout: () => _handleLogout(auth)), SizedBox(height: AppSpacing.lg), @@ -324,8 +329,13 @@ class _InfoRow extends StatelessWidget { class _MenuList extends StatelessWidget { final void Function(String) onShowComingSoon; final VoidCallback onShowAbout; + final int kycStatus; - const _MenuList({required this.onShowComingSoon, required this.onShowAbout}); + const _MenuList({ + required this.onShowComingSoon, + required this.onShowAbout, + required this.kycStatus, + }); @override Widget build(BuildContext context) { @@ -341,7 +351,7 @@ class _MenuList extends StatelessWidget { _ThemeToggleTile(isDarkMode: themeProvider.isDarkMode), _buildDivider(), // 菜单项 - ..._buildMenuItems(colorScheme), + ..._buildMenuItems(context, colorScheme), ], ), ); @@ -355,14 +365,27 @@ class _MenuList extends StatelessWidget { ); } - List _buildMenuItems(ColorScheme colorScheme) { + List _buildMenuItems(BuildContext context, ColorScheme colorScheme) { final items = [ _MenuItem( icon: LucideIcons.userCheck, title: '实名认证', - subtitle: '完成实名认证,解锁更多功能', - iconColor: colorScheme.primary, - onTap: () => onShowComingSoon('实名认证'), + subtitle: kycStatus == 2 + ? '已认证' + : kycStatus == 1 + ? '审核中' + : '完成实名认证,解锁更多功能', + iconColor: kycStatus == 2 ? AppColorScheme.up : colorScheme.primary, + onTap: () { + if (kycStatus == 2) { + _showKycStatusDialog(context); + } else { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const KycPage()), + ); + } + }, ), _MenuItem( icon: LucideIcons.shield, @@ -403,6 +426,28 @@ class _MenuList extends StatelessWidget { } } +void _showKycStatusDialog(BuildContext context) { + showShadDialog( + context: context, + builder: (ctx) => ShadDialog.alert( + title: Row( + children: [ + Icon(Icons.check_circle, color: AppColorScheme.up, size: 20), + SizedBox(width: AppSpacing.sm), + const Text('实名认证'), + ], + ), + description: const Text('您的实名认证已通过'), + actions: [ + ShadButton( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); +} + /// 主题切换组件 class _ThemeToggleTile extends StatelessWidget { final bool isDarkMode; diff --git a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart index 274b5ff..3df24fc 100644 --- a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart +++ b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart @@ -84,10 +84,8 @@ class _TradePageState extends State if (price <= 0) return '0'; if (_tradeType == 0) { - // 买入:最大 = USDT 余额 return _availableUsdt; } else { - // 卖出:最大 = 持有数量 × 当前价格 final qty = double.tryParse(_availableCoinQty) ?? 0; return (qty * price).toStringAsFixed(2); } @@ -118,7 +116,10 @@ class _TradePageState extends State _CoinSelector( selectedCoin: _selectedCoin, coins: market.allCoins - .where((c) => c.code != 'USDT') + .where((c) => + c.code != 'USDT' && + c.code != 'BTC' && + c.code != 'ETH') .toList(), onCoinSelected: (coin) { setState(() { @@ -133,7 +134,7 @@ class _TradePageState extends State if (_selectedCoin != null) _PriceCard(coin: _selectedCoin!) else - _PlaceholderCard(message: '请先选择交易币种'), + const _PlaceholderCard(message: '请先选择交易币种'), SizedBox(height: AppSpacing.md), @@ -197,7 +198,13 @@ class _TradePageState extends State bool _canTrade() { if (_selectedCoin == null) return false; final amount = double.tryParse(_amountController.text) ?? 0; - return amount > 0; + if (amount <= 0) return false; + // 买入时校验不超过可用USDT + if (_tradeType == 0) { + final available = double.tryParse(_availableUsdt) ?? 0; + if (amount > available) return false; + } + return true; } void _fillPercent(double pct) { @@ -524,22 +531,52 @@ class _CoinSelector extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 第一行:币种代码 + 价格 + 涨跌幅 Row( children: [ Text(coin.code, style: GoogleFonts.spaceGrotesk( - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.bold, color: colorScheme.onSurface, )), SizedBox(width: AppSpacing.xs), Text('/USDT', style: TextStyle( - fontSize: 12, + fontSize: 11, color: colorScheme.onSurfaceVariant, )), + const Spacer(), + Text('\$${coin.formattedPrice}', + style: GoogleFonts.spaceGrotesk( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + )), + SizedBox(width: AppSpacing.sm), + Container( + padding: EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: changeColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text(coin.formattedChange, + style: TextStyle( + fontSize: 11, + color: changeColor, + fontWeight: FontWeight.w600, + )), + ), + if (isSelected) ...[ + SizedBox(width: AppSpacing.sm), + Icon(LucideIcons.check, + size: 16, color: colorScheme.primary), + ], ], ), + SizedBox(height: 3), + // 第二行:币种名称 Text(coin.name, style: TextStyle( fontSize: 12, @@ -548,27 +585,6 @@ class _CoinSelector extends StatelessWidget { ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('\$${coin.formattedPrice}', - style: GoogleFonts.spaceGrotesk( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - )), - Text(coin.formattedChange, - style: TextStyle( - fontSize: 12, - color: changeColor, - fontWeight: FontWeight.w600, - )), - ], - ), - if (isSelected) ...[ - SizedBox(width: AppSpacing.sm), - Icon(LucideIcons.check, size: 18, color: colorScheme.primary), - ], ], ), ), @@ -604,7 +620,7 @@ class _CoinAvatar extends StatelessWidget { } } -/// 价格卡片 +/// 价格卡片 - 重新设计 class _PriceCard extends StatelessWidget { final Coin coin; const _PriceCard({required this.coin}); @@ -613,48 +629,81 @@ class _PriceCard extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - final color = - coin.isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down; - final bgColor = coin.isUp + final isUp = coin.isUp; + final changeColor = + isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down; + final changeBgColor = isUp ? AppColorScheme.getUpBackgroundColor(isDark) : colorScheme.error.withOpacity(0.1); return GlassPanel( - padding: EdgeInsets.all(AppSpacing.lg), + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, vertical: AppSpacing.md + AppSpacing.sm), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('最新价', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - )), - SizedBox(height: AppSpacing.xs), - Text( - '\$${coin.formattedPrice}', - style: GoogleFonts.spaceGrotesk( - fontSize: 28, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + // 左侧:币种标签 + 价格 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + '${coin.code}/USDT', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + ), + ], ), - ), - ], + SizedBox(height: AppSpacing.sm), + Text( + '\$${coin.formattedPrice}', + style: GoogleFonts.spaceGrotesk( + fontSize: 30, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ], + ), ), + // 右侧:涨跌幅 Container( padding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, vertical: AppSpacing.sm), + horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2), decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: color.withOpacity(0.2)), + color: changeBgColor, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: changeColor.withOpacity(0.15)), ), - child: Text( - coin.formattedChange, - style: TextStyle( - fontSize: 16, color: color, fontWeight: FontWeight.w700), + child: Column( + children: [ + Text( + '24h', + style: TextStyle( + fontSize: 10, + color: changeColor.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2), + Text( + coin.formattedChange, + style: TextStyle( + fontSize: 16, color: changeColor, fontWeight: FontWeight.w700), + ), + ], ), ), ], @@ -684,7 +733,7 @@ class _PlaceholderCard extends StatelessWidget { } } -/// 交易表单卡片 +/// 交易表单卡片 - 重新设计 class _TradeFormCard extends StatelessWidget { final int tradeType; final Coin? selectedCoin; @@ -721,9 +770,9 @@ class _TradeFormCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 买入/卖出切换 + // 买入/卖出切换 - 重新设计 Container( - padding: EdgeInsets.all(AppSpacing.xs), + padding: EdgeInsets.all(4), decoration: BoxDecoration( color: colorScheme.surfaceContainerLowest, borderRadius: BorderRadius.circular(AppRadius.xl), @@ -731,11 +780,25 @@ class _TradeFormCard extends StatelessWidget { child: Row( children: [ Expanded( - child: _typeButton('买入', isBuy, AppColorScheme.up, () => onTradeTypeChanged(0)), + child: _buildTypeButton( + context: context, + label: '买入', + isActive: isBuy, + color: AppColorScheme.up, + icon: LucideIcons.trendingUp, + onTap: () => onTradeTypeChanged(0), + ), ), - SizedBox(width: AppSpacing.sm), + SizedBox(width: 4), Expanded( - child: _typeButton('卖出', !isBuy, AppColorScheme.down, () => onTradeTypeChanged(1)), + child: _buildTypeButton( + context: context, + label: '卖出', + isActive: !isBuy, + color: AppColorScheme.down, + icon: LucideIcons.trendingDown, + onTap: () => onTradeTypeChanged(1), + ), ), ], ), @@ -743,157 +806,188 @@ class _TradeFormCard extends StatelessWidget { SizedBox(height: AppSpacing.lg), // 交易金额输入 - Text('交易金额 (USDT)', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - color: colorScheme.onSurfaceVariant, - )), - SizedBox(height: AppSpacing.xs), - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.3)), - ), - child: TextField( - controller: amountController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (_) => onAmountChanged(), - style: GoogleFonts.spaceGrotesk( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: '输入金额', - hintStyle: TextStyle( - color: colorScheme.outlineVariant.withOpacity(0.5)), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - suffixIcon: Padding( - padding: EdgeInsets.only(right: AppSpacing.sm), - child: Text('USDT', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSurfaceVariant, - )), - ), - suffixIconConstraints: const BoxConstraints(minWidth: 50), - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('交易金额', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + )), + Text('USDT', + style: GoogleFonts.spaceGrotesk( + fontSize: 11, + fontWeight: FontWeight.w700, + color: actionColor.withOpacity(0.7), + letterSpacing: 0.5, + )), + ], + ), + SizedBox(height: AppSpacing.sm), + _AmountInput( + amountController: amountController, + maxAmount: maxAmount, + isBuy: isBuy, + actionColor: actionColor, + onChanged: onAmountChanged, ), SizedBox(height: AppSpacing.sm), - // 快捷比例按钮 + // 快捷比例按钮 - 药丸样式 Row( children: [ - _pctButton('25%', 0.25, colorScheme), - SizedBox(width: AppSpacing.xs), - _pctButton('50%', 0.5, colorScheme), - SizedBox(width: AppSpacing.xs), - _pctButton('75%', 0.75, colorScheme), - SizedBox(width: AppSpacing.xs), - _pctButton('全部', 1.0, colorScheme), + _buildPctButton('25%', 0.25, colorScheme, actionColor), + SizedBox(width: 6), + _buildPctButton('50%', 0.5, colorScheme, actionColor), + SizedBox(width: 6), + _buildPctButton('75%', 0.75, colorScheme, actionColor), + SizedBox(width: 6), + _buildPctButton('全部', 1.0, colorScheme, actionColor), ], ), SizedBox(height: AppSpacing.lg), - // 预计数量 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('预计数量', - style: TextStyle( - fontSize: 13, - color: colorScheme.onSurfaceVariant, - )), - Text( - '$calculatedQuantity ${selectedCoin?.code ?? ''}', - style: GoogleFonts.spaceGrotesk( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + // 预计数量 + 可用余额 - 卡片样式 + Container( + padding: EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: colorScheme.outlineVariant.withOpacity(0.1)), + ), + child: Column( + children: [ + // 预计数量 + Row( + children: [ + Icon(LucideIcons.calculator, + size: 14, color: colorScheme.onSurfaceVariant), + SizedBox(width: AppSpacing.sm), + Text('预计数量', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + )), + const Spacer(), + Text( + '$calculatedQuantity ${selectedCoin?.code ?? ''}', + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], ), - ), - ], - ), - SizedBox(height: AppSpacing.md), - - // 可用余额 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}', - style: TextStyle( - fontSize: 13, - color: colorScheme.onSurfaceVariant, - )), - Text( - isBuy - ? '$availableUsdt USDT' - : '$availableCoinQty ${selectedCoin?.code ?? ""}', - style: GoogleFonts.spaceGrotesk( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Divider( + height: 1, + color: colorScheme.outlineVariant.withOpacity(0.08)), ), - ), - ], + // 可用余额 + Row( + children: [ + Icon(LucideIcons.wallet, + size: 14, color: colorScheme.onSurfaceVariant), + SizedBox(width: AppSpacing.sm), + Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + )), + const Spacer(), + Text( + isBuy + ? '$availableUsdt USDT' + : '$availableCoinQty ${selectedCoin?.code ?? ""}', + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ], + ), ), ], ), ); } - Widget _typeButton( - String label, bool isActive, Color color, VoidCallback onTap) { + /// 买入/卖出切换按钮 - 实心填充 + 图标 + 光效 + Widget _buildTypeButton({ + required BuildContext context, + required String label, + required bool isActive, + required Color color, + required IconData icon, + required VoidCallback onTap, + }) { return GestureDetector( onTap: onTap, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + AppSpacing.xs), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + 4), decoration: BoxDecoration( - color: isActive ? color.withOpacity(0.15) : Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.md), - border: isActive ? null : Border.all(color: color.withOpacity(0.3)), + color: isActive ? color : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: isActive + ? [ + BoxShadow( + color: color.withOpacity(0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ] + : null, ), - child: Center( - child: Text( - label, - style: TextStyle( - color: isActive ? color : color.withOpacity(0.7), - fontWeight: FontWeight.w700, - fontSize: 14, - letterSpacing: 0.5, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, + color: isActive ? Colors.white : color.withOpacity(0.5)), + SizedBox(width: AppSpacing.xs), + Text( + label, + style: TextStyle( + color: isActive ? Colors.white : color.withOpacity(0.5), + fontWeight: FontWeight.w700, + fontSize: 15, + letterSpacing: 0.5, + ), ), - ), + ], ), ), ); } - Widget _pctButton(String label, double pct, ColorScheme colorScheme) { + /// 百分比按钮 - 药丸样式 + Widget _buildPctButton( + String label, double pct, ColorScheme colorScheme, Color actionColor) { return Expanded( child: GestureDetector( onTap: () => onFillPercent(pct), child: Container( - padding: EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), + padding: EdgeInsets.symmetric(vertical: AppSpacing.sm - 2), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), + color: actionColor.withOpacity(0.06), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: actionColor.withOpacity(0.12)), ), child: Center( child: Text(label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, + color: actionColor.withOpacity(0.8), )), ), ), @@ -902,7 +996,7 @@ class _TradeFormCard extends StatelessWidget { } } -/// 交易按钮 +/// 交易按钮 - 使用 NeonButton 风格 class _TradeButton extends StatelessWidget { final bool isBuy; final String? coinCode; @@ -921,42 +1015,199 @@ class _TradeButton extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final actionColor = isBuy ? AppColorScheme.up : AppColorScheme.down; + final gradient = isBuy + ? AppColorScheme.getBuyGradient(isDark) + : AppColorScheme.sellGradient; - return SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: enabled ? onPressed : null, - style: ElevatedButton.styleFrom( - backgroundColor: - isBuy ? AppColorScheme.up : AppColorScheme.down, - disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.xl), - ), - elevation: isBuy && enabled ? 4 : 0, - shadowColor: - isBuy ? AppColorScheme.up.withOpacity(0.3) : Colors.transparent, + return GestureDetector( + onTap: enabled ? onPressed : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 52, + decoration: BoxDecoration( + gradient: enabled ? gradient : null, + color: enabled ? null : colorScheme.onSurface.withOpacity(0.08), + borderRadius: BorderRadius.circular(AppRadius.xxl), + boxShadow: enabled + ? [ + BoxShadow( + color: actionColor.withOpacity(isDark ? 0.25 : 0.15), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ] + : null, ), - child: isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: isBuy ? Colors.black87 : Colors.white, + child: Center( + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isBuy + ? AppColorScheme.darkOnTertiary + : Colors.white, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isBuy ? LucideIcons.trendingUp : LucideIcons.trendingDown, + size: 16, + color: enabled + ? (isBuy + ? AppColorScheme.darkOnTertiary + : Colors.white) + : colorScheme.onSurface.withOpacity(0.3), + ), + SizedBox(width: AppSpacing.xs), + Text( + '${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}', + style: GoogleFonts.spaceGrotesk( + fontSize: 16, + fontWeight: FontWeight.w700, + color: enabled + ? (isBuy + ? AppColorScheme.darkOnTertiary + : Colors.white) + : colorScheme.onSurface.withOpacity(0.3), + letterSpacing: 0.5, + ), + ), + ], ), - ) - : Text( - '${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}', - style: GoogleFonts.spaceGrotesk( - fontSize: 16, - fontWeight: FontWeight.w700, - color: isBuy ? Colors.black87 : Colors.white, - letterSpacing: 1, - ), - ), + ), ), ); } } + +/// 金额输入框(含超额提示) +class _AmountInput extends StatefulWidget { + final TextEditingController amountController; + final String maxAmount; + final bool isBuy; + final Color actionColor; + final VoidCallback onChanged; + + const _AmountInput({ + required this.amountController, + required this.maxAmount, + required this.isBuy, + required this.actionColor, + required this.onChanged, + }); + + @override + State<_AmountInput> createState() => _AmountInputState(); +} + +class _AmountInputState extends State<_AmountInput> { + bool _isExceeded = false; + + void _checkLimit() { + final input = double.tryParse(widget.amountController.text) ?? 0; + final max = double.tryParse(widget.maxAmount) ?? 0; + final exceeded = widget.isBuy && input > max && max > 0 && input > 0; + if (exceeded != _isExceeded) { + setState(() => _isExceeded = exceeded); + } + widget.onChanged(); + } + + @override + void initState() { + super.initState(); + widget.amountController.addListener(_checkLimit); + } + + @override + void dispose() { + widget.amountController.removeListener(_checkLimit); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final warningColor = AppColorScheme.warning; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all( + color: _isExceeded + ? warningColor.withOpacity(0.5) + : widget.actionColor.withOpacity(0.15), + ), + ), + child: TextField( + controller: widget.amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _checkLimit(), + style: GoogleFonts.spaceGrotesk( + fontSize: 22, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: '0.00', + hintStyle: TextStyle( + color: colorScheme.outlineVariant.withOpacity(0.4)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + suffixIcon: Padding( + padding: EdgeInsets.only(right: AppSpacing.sm), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, vertical: AppSpacing.xs), + decoration: BoxDecoration( + color: widget.actionColor.withOpacity(0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text('USDT', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: widget.actionColor.withOpacity(0.7), + )), + ), + ], + ), + ), + suffixIconConstraints: const BoxConstraints(minWidth: 60), + ), + ), + ), + if (_isExceeded) + Padding( + padding: EdgeInsets.only(top: AppSpacing.xs), + child: Row( + children: [ + Icon(Icons.error_outline, size: 13, color: warningColor), + SizedBox(width: 4), + Text( + '超出可用USDT余额', + style: TextStyle(fontSize: 11, color: warningColor), + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter_monisuo/pubspec.lock b/flutter_monisuo/pubspec.lock index 7056d57..1fee162 100644 --- a/flutter_monisuo/pubspec.lock +++ b/flutter_monisuo/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -137,6 +145,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -163,6 +203,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_shaders: dependency: transitive description: @@ -237,6 +285,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd" + url: "https://pub.dev" + source: hosted + version: "0.8.13+15" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: diff --git a/flutter_monisuo/pubspec.yaml b/flutter_monisuo/pubspec.yaml index 176a447..ba45ded 100644 --- a/flutter_monisuo/pubspec.yaml +++ b/flutter_monisuo/pubspec.yaml @@ -31,6 +31,9 @@ dependencies: intl: ^0.20.2 decimal: ^2.3.3 + # 图片选择 + image_picker: ^1.0.7 + # 字体 google_fonts: ^6.2.1 diff --git a/src/main/java/com/it/rattan/config/WebConfig.java b/src/main/java/com/it/rattan/config/WebConfig.java index b2eade3..b89ec7f 100644 --- a/src/main/java/com/it/rattan/config/WebConfig.java +++ b/src/main/java/com/it/rattan/config/WebConfig.java @@ -5,11 +5,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.File; import java.io.IOException; @Configuration @@ -20,6 +22,14 @@ public class WebConfig implements WebMvcConfigurer { } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 映射上传目录,使 /uploads/** 可访问上传的文件 + String uploadPath = System.getProperty("user.dir") + File.separator + "uploads" + File.separator; + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:" + uploadPath); + } + /** * 跨域过滤器 - 支持凭证,最高优先级 */ diff --git a/src/main/java/com/it/rattan/monisuo/controller/AnalysisController.java b/src/main/java/com/it/rattan/monisuo/controller/AnalysisController.java index cffb3ec..bde82ee 100644 --- a/src/main/java/com/it/rattan/monisuo/controller/AnalysisController.java +++ b/src/main/java/com/it/rattan/monisuo/controller/AnalysisController.java @@ -2,8 +2,6 @@ package com.it.rattan.monisuo.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.it.rattan.monisuo.common.Result; -import com.it.rattan.monisuo.entity.OrderFund; -import com.it.rattan.monisuo.entity.OrderTrade; import com.it.rattan.monisuo.entity.User; import com.it.rattan.monisuo.mapper.AccountFundMapper; import com.it.rattan.monisuo.mapper.OrderFundMapper; @@ -18,9 +16,15 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.stream.Collectors; /** - * 业务分析接口 + * 业务分析接口 - 性能优化版 + * + * 优化点: + * 1. 资金流动趋势:循环 N 次查询 → 1 次 GROUP BY 查询 + * 2. 交易分析:循环 N×2 次查询 → 1 次 GROUP BY 查询 + * 3. 用户增长:循环 N×2 次查询 → 批量查询 + GROUP BY */ @RestController @RequestMapping("/admin/analysis") @@ -44,24 +48,22 @@ public class AnalysisController { @GetMapping("/profit") public Result> getProfitAnalysis( @RequestParam(defaultValue = "month") String range) { - + Map data = new HashMap<>(); - - // 根据时间范围计算 LocalDateTime startTime = getStartTime(range); - + // 交易手续费 (0.1%) BigDecimal tradeFee = orderTradeMapper.sumFeeByTime(startTime); if (tradeFee == null) tradeFee = BigDecimal.ZERO; data.put("tradeFee", tradeFee); data.put("tradeFeeRate", "0.1%"); - + // 充提手续费 (0.5%) BigDecimal fundFee = orderFundMapper.sumFeeByTime(startTime); if (fundFee == null) fundFee = BigDecimal.ZERO; data.put("fundFee", fundFee); data.put("fundFeeRate", "0.5%"); - + // 资金利差 (年化3.5%,按天数计算) BigDecimal fundBalance = accountFundMapper.sumAllBalance(); if (fundBalance == null) fundBalance = BigDecimal.ZERO; @@ -70,103 +72,126 @@ public class AnalysisController { BigDecimal interestProfit = fundBalance.multiply(interestRate).multiply(new BigDecimal(days)); data.put("interestProfit", interestProfit.setScale(2, RoundingMode.HALF_UP)); data.put("interestRate", "年化3.5%"); - + // 总收益 BigDecimal totalProfit = tradeFee.add(fundFee).add(interestProfit); data.put("totalProfit", totalProfit.setScale(2, RoundingMode.HALF_UP)); - + return Result.success(data); } /** - * 资金流动趋势 + * 资金流动趋势(优化:1 次 GROUP BY 替代 N 次循环查询) */ @GetMapping("/cash-flow") public Result>> getCashFlowTrend( @RequestParam(defaultValue = "6") int months) { - - List> result = new ArrayList<>(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M月"); - + + // 计算查询范围 + LocalDate firstMonth = LocalDate.now().minusMonths(months - 1).withDayOfMonth(1); + LocalDateTime queryStart = firstMonth.atStartOfDay(); + LocalDateTime queryEnd = LocalDate.now().plusMonths(1).withDayOfMonth(1).atStartOfDay(); + + // 一次性查询所有月份数据 + List> dbResults = orderFundMapper.sumMonthlyFundFlow(queryStart, queryEnd); + Map> dbMap = dbResults.stream() + .collect(Collectors.toMap( + m -> (String) m.get("month"), + m -> m, + (a, b) -> a + )); + + // 组装结果(保持原有月份顺序和格式) + List> result = new ArrayList<>(); for (int i = months - 1; i >= 0; i--) { LocalDate monthStart = LocalDate.now().minusMonths(i).withDayOfMonth(1); - LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1); - - LocalDateTime start = monthStart.atStartOfDay(); - LocalDateTime end = monthEnd.atTime(23, 59, 59); - + String monthKey = monthStart.format(DateTimeFormatter.ofPattern("yyyy-MM")); + Map item = new HashMap<>(); item.put("month", monthStart.format(formatter)); - - // 充值 - BigDecimal deposit = orderFundMapper.sumDepositByTime(start, end); - item.put("deposit", deposit != null ? deposit : BigDecimal.ZERO); - - // 提现 - BigDecimal withdraw = orderFundMapper.sumWithdrawByTime(start, end); - item.put("withdraw", withdraw != null ? withdraw : BigDecimal.ZERO); - - // 净流入 - BigDecimal netInflow = (deposit != null ? deposit : BigDecimal.ZERO) - .subtract(withdraw != null ? withdraw : BigDecimal.ZERO); - item.put("netInflow", netInflow); - + + Map dbData = dbMap.get(monthKey); + BigDecimal deposit = BigDecimal.ZERO; + BigDecimal withdraw = BigDecimal.ZERO; + if (dbData != null) { + deposit = dbData.get("deposit") instanceof BigDecimal ? (BigDecimal) dbData.get("deposit") : BigDecimal.ZERO; + withdraw = dbData.get("withdraw") instanceof BigDecimal ? (BigDecimal) dbData.get("withdraw") : BigDecimal.ZERO; + } + + item.put("deposit", deposit); + item.put("withdraw", withdraw); + item.put("netInflow", deposit.subtract(withdraw)); result.add(item); } - + return Result.success(result); } /** - * 交易分析 + * 交易分析(优化:1 次 GROUP BY 替代 N×2 次循环查询) */ @GetMapping("/trade") public Result> getTradeAnalysis( @RequestParam(defaultValue = "week") String range) { - + Map data = new HashMap<>(); LocalDateTime startTime = getStartTime(range); - - // 买入统计 - BigDecimal buyAmount = orderTradeMapper.sumAmountByTypeAndTime(1, startTime); - int buyCount = orderTradeMapper.countByTypeAndTime(1, startTime); + + // 总买入统计 + BigDecimal buyAmount = orderTradeMapper.sumAmountByDirectionAndTime(1, startTime); + int buyCount = orderTradeMapper.countByDirectionAndTime(1, startTime); data.put("buyAmount", buyAmount != null ? buyAmount : BigDecimal.ZERO); data.put("buyCount", buyCount); - - // 卖出统计 - BigDecimal sellAmount = orderTradeMapper.sumAmountByTypeAndTime(2, startTime); - int sellCount = orderTradeMapper.countByTypeAndTime(2, startTime); + + // 总卖出统计 + BigDecimal sellAmount = orderTradeMapper.sumAmountByDirectionAndTime(2, startTime); + int sellCount = orderTradeMapper.countByDirectionAndTime(2, startTime); data.put("sellAmount", sellAmount != null ? sellAmount : BigDecimal.ZERO); data.put("sellCount", sellCount); - + // 净买入 BigDecimal netBuy = (buyAmount != null ? buyAmount : BigDecimal.ZERO) .subtract(sellAmount != null ? sellAmount : BigDecimal.ZERO); data.put("netBuy", netBuy); - - // 交易趋势(按天) - List> trend = new ArrayList<>(); + + // 交易趋势:1 次 GROUP BY 查询替代 N×2 次循环查询 int days = "week".equals(range) ? 7 : 30; + LocalDateTime trendStart = LocalDate.now().minusDays(days - 1).atStartOfDay(); + LocalDateTime trendEnd = LocalDate.now().plusDays(1).atStartOfDay(); + + List> dailyResults = orderTradeMapper.sumDailyTradeAmount(trendStart, trendEnd); + Map> dailyMap = dailyResults.stream() + .collect(Collectors.toMap( + m -> m.get("date").toString(), + m -> m, + (a, b) -> a + )); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M-d"); - + List> trend = new ArrayList<>(); for (int i = days - 1; i >= 0; i--) { LocalDate date = LocalDate.now().minusDays(i); - LocalDateTime dayStart = date.atStartOfDay(); - LocalDateTime dayEnd = date.atTime(23, 59, 59); - + String dateKey = date.toString(); + Map item = new HashMap<>(); item.put("date", date.format(formatter)); - - BigDecimal dayBuy = orderTradeMapper.sumAmountByTypeAndTimeRange(1, dayStart, dayEnd); - BigDecimal daySell = orderTradeMapper.sumAmountByTypeAndTimeRange(2, dayStart, dayEnd); - - item.put("buy", dayBuy != null ? dayBuy : BigDecimal.ZERO); - item.put("sell", daySell != null ? daySell : BigDecimal.ZERO); - + + Map dayData = dailyMap.get(dateKey); + BigDecimal dayBuy = BigDecimal.ZERO; + BigDecimal daySell = BigDecimal.ZERO; + if (dayData != null) { + dayBuy = dayData.get("buy") instanceof BigDecimal ? (BigDecimal) dayData.get("buy") : BigDecimal.ZERO; + daySell = dayData.get("sell") instanceof BigDecimal ? (BigDecimal) dayData.get("sell") : BigDecimal.ZERO; + } + + item.put("buy", dayBuy); + item.put("sell", daySell); trend.add(item); } data.put("trend", trend); - + return Result.success(data); } @@ -176,60 +201,58 @@ public class AnalysisController { @GetMapping("/coin-distribution") public Result>> getCoinDistribution( @RequestParam(defaultValue = "month") String range) { - + LocalDateTime startTime = getStartTime(range); List> result = orderTradeMapper.sumAmountGroupByCoin(startTime); - + return Result.success(result); } /** - * 用户增长分析 + * 用户增长分析(优化:减少循环查询次数) */ @GetMapping("/user-growth") public Result> getUserGrowth( @RequestParam(defaultValue = "6") int months) { - + Map data = new HashMap<>(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M月"); - + // 月度趋势 List> trend = new ArrayList<>(); for (int i = months - 1; i >= 0; i--) { LocalDate monthStart = LocalDate.now().minusMonths(i).withDayOfMonth(1); LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1); - + LocalDateTime start = monthStart.atStartOfDay(); LocalDateTime end = monthEnd.atTime(23, 59, 59); - + Map item = new HashMap<>(); item.put("month", monthStart.format(formatter)); - - // 新增用户 + int newUsers = userService.count(new LambdaQueryWrapper() .ge(User::getCreateTime, start) .le(User::getCreateTime, end)); item.put("newUsers", newUsers); - - // 活跃用户(有交易的) + int activeUsers = orderTradeMapper.countDistinctUserByTime(start, end); item.put("activeUsers", activeUsers); - + trend.add(item); } data.put("trend", trend); - + // 当前统计 int totalUsers = (int) userService.count(); int monthNewUsers = userService.count(new LambdaQueryWrapper() .ge(User::getCreateTime, LocalDate.now().withDayOfMonth(1).atStartOfDay())); int activeUsersToday = orderTradeMapper.countDistinctUserByTime( LocalDate.now().atStartOfDay(), LocalDateTime.now()); - + data.put("totalUsers", totalUsers); data.put("monthNewUsers", monthNewUsers); data.put("activeUsersToday", activeUsersToday); - + return Result.success(data); } @@ -239,28 +262,24 @@ public class AnalysisController { @GetMapping("/risk") public Result> getRiskMetrics() { Map data = new HashMap<>(); - - // 大额交易 (>50000) + int largeTransactions = orderFundMapper.countLargeAmount(new BigDecimal("50000")); data.put("largeTransactions", largeTransactions); - data.put("largeTransactionThreshold", ">¥50,000"); - - // 异常提现 (24小时内>3次) + data.put("largeTransactionThreshold", ">50,000 USDT"); + LocalDateTime yesterday = LocalDateTime.now().minusHours(24); int abnormalWithdrawals = orderFundMapper.countAbnormalWithdrawals(yesterday, 3); data.put("abnormalWithdrawals", abnormalWithdrawals); data.put("abnormalWithdrawalThreshold", "24h内>3次"); - - // 待审KYC (这里简化为未实名用户) + int pendingKyc = userService.count(new LambdaQueryWrapper() .eq(User::getKycStatus, 0)); data.put("pendingKyc", pendingKyc); - - // 冻结账户 + int frozenAccounts = userService.count(new LambdaQueryWrapper() .eq(User::getStatus, 0)); data.put("frozenAccounts", frozenAccounts); - + return Result.success(data); } @@ -270,41 +289,36 @@ public class AnalysisController { @GetMapping("/health") public Result> getHealthScore() { Map data = new HashMap<>(); - - // 流动性评分 (在管资金/总资产) + BigDecimal fundBalance = accountFundMapper.sumAllBalance(); BigDecimal tradeValue = accountFundMapper.sumAllTradeValue(); BigDecimal totalAsset = (fundBalance != null ? fundBalance : BigDecimal.ZERO) .add(tradeValue != null ? tradeValue : BigDecimal.ZERO); - + int liquidityScore = 100; if (totalAsset.compareTo(BigDecimal.ZERO) > 0 && fundBalance != null) { BigDecimal ratio = fundBalance.divide(totalAsset, 2, RoundingMode.HALF_UP); liquidityScore = ratio.multiply(new BigDecimal(100)).intValue(); } - - // 风险评分 (基于异常交易) + int abnormalCount = orderFundMapper.countAbnormalWithdrawals( LocalDateTime.now().minusHours(24), 3); int riskScore = Math.max(0, 100 - abnormalCount * 10); - - // 稳定性评分 (基于用户增长) + int monthNewUsers = userService.count(new LambdaQueryWrapper() .ge(User::getCreateTime, LocalDate.now().withDayOfMonth(1).atStartOfDay())); int stabilityScore = Math.min(100, 50 + monthNewUsers); - - // 综合评分 + int overallScore = (liquidityScore + riskScore + stabilityScore) / 3; - + data.put("overallScore", overallScore); data.put("liquidityScore", liquidityScore); data.put("riskScore", riskScore); data.put("stabilityScore", stabilityScore); - - // 评级 + String grade = overallScore >= 80 ? "优秀" : overallScore >= 60 ? "良好" : "需改进"; data.put("grade", grade); - + return Result.success(data); } diff --git a/src/main/java/com/it/rattan/monisuo/controller/FundController.java b/src/main/java/com/it/rattan/monisuo/controller/FundController.java index 686506f..8936f17 100644 --- a/src/main/java/com/it/rattan/monisuo/controller/FundController.java +++ b/src/main/java/com/it/rattan/monisuo/controller/FundController.java @@ -6,7 +6,9 @@ import com.it.rattan.monisuo.context.UserContext; import com.it.rattan.monisuo.dto.DepositRequest; import com.it.rattan.monisuo.dto.WithdrawRequest; import com.it.rattan.monisuo.entity.OrderFund; +import com.it.rattan.monisuo.entity.User; import com.it.rattan.monisuo.service.FundService; +import com.it.rattan.monisuo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; @@ -22,6 +24,9 @@ public class FundController { @Autowired private FundService fundService; + @Autowired + private UserService userService; + /** * 申请充值 */ @@ -80,6 +85,12 @@ public class FundController { return Result.unauthorized("请先登录"); } + // KYC校验:提现前必须完成实名认证 + User user = userService.getById(userId); + if (user == null || user.getKycStatus() == null || user.getKycStatus() != 2) { + return Result.fail("KYC_REQUIRED", "请先完成实名认证"); + } + BigDecimal amount = request.getAmount(); String withdrawAddress = request.getWithdrawAddress(); String withdrawContact = request.getWithdrawContact(); diff --git a/src/main/java/com/it/rattan/monisuo/controller/UserController.java b/src/main/java/com/it/rattan/monisuo/controller/UserController.java index b2d8b50..84b61dc 100644 --- a/src/main/java/com/it/rattan/monisuo/controller/UserController.java +++ b/src/main/java/com/it/rattan/monisuo/controller/UserController.java @@ -5,6 +5,7 @@ import com.it.rattan.monisuo.context.UserContext; import com.it.rattan.monisuo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.Map; /** @@ -82,27 +83,26 @@ public class UserController { } /** - * 上传KYC资料 + * 上传KYC资料(身份证正反面图片) */ @PostMapping("/kyc") - public Result uploadKyc(@RequestBody Map params) { + public Result uploadKyc( + @RequestPart("front") MultipartFile front, + @RequestPart("back") MultipartFile back) { Long userId = UserContext.getUserId(); if (userId == null) { return Result.unauthorized("请先登录"); } - String idCardFront = params.get("idCardFront"); - String idCardBack = params.get("idCardBack"); - - if (idCardFront == null || idCardFront.isEmpty()) { + if (front == null || front.isEmpty()) { return Result.fail("请上传身份证正面照"); } - if (idCardBack == null || idCardBack.isEmpty()) { + if (back == null || back.isEmpty()) { return Result.fail("请上传身份证反面照"); } try { - userService.uploadKyc(userId, idCardFront, idCardBack); + userService.uploadKyc(userId, front, back); return Result.success("上传成功", null); } catch (Exception e) { return Result.fail(e.getMessage()); diff --git a/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java b/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java index 048b7c5..a6e77a0 100644 --- a/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java +++ b/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java @@ -26,6 +26,7 @@ public class TokenFilter implements Filter { "/api/user/login", "/api/wallet/default", "/admin/login", + "/uploads/", "/swagger-resources", "/v2/api-docs", "/webjars/", diff --git a/src/main/java/com/it/rattan/monisuo/mapper/OrderFundMapper.java b/src/main/java/com/it/rattan/monisuo/mapper/OrderFundMapper.java index 9c0f9cb..755752b 100644 --- a/src/main/java/com/it/rattan/monisuo/mapper/OrderFundMapper.java +++ b/src/main/java/com/it/rattan/monisuo/mapper/OrderFundMapper.java @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; /** * 充提订单Mapper @@ -26,23 +28,11 @@ public interface OrderFundMapper extends BaseMapper { // ========== 分析相关查询 ========== /** - * 指定时间段内的手续费总额 + * 指定时间段内的手续费总额(0.5%) */ @Select("SELECT IFNULL(SUM(amount * 0.005), 0) FROM order_fund WHERE status = 2 AND create_time >= #{startTime}") BigDecimal sumFeeByTime(@Param("startTime") LocalDateTime startTime); - /** - * 指定时间段内的充值总额 - */ - @Select("SELECT IFNULL(SUM(amount), 0) FROM order_fund WHERE type = 1 AND status = 2 AND create_time >= #{startTime} AND create_time < #{endTime}") - BigDecimal sumDepositByTime(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); - - /** - * 指定时间段内的提现总额 - */ - @Select("SELECT IFNULL(SUM(amount), 0) FROM order_fund WHERE type = 2 AND status = 2 AND create_time >= #{startTime} AND create_time < #{endTime}") - BigDecimal sumWithdrawByTime(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); - /** * 大额交易数量 */ @@ -55,4 +45,14 @@ public interface OrderFundMapper extends BaseMapper { @Select("SELECT COUNT(DISTINCT user_id) FROM order_fund WHERE type = 2 AND create_time >= #{startTime} AND user_id IN " + "(SELECT user_id FROM order_fund WHERE type = 2 AND create_time >= #{startTime} GROUP BY user_id HAVING COUNT(*) >= #{minCount})") int countAbnormalWithdrawals(@Param("startTime") LocalDateTime startTime, @Param("minCount") int minCount); + + /** + * 按月分组统计充值/提现金额(替代循环 N 次查询) + */ + @Select("SELECT DATE_FORMAT(create_time, '%Y-%m') as month, " + + "IFNULL(SUM(CASE WHEN type = 1 AND status = 2 THEN amount ELSE 0 END), 0) as deposit, " + + "IFNULL(SUM(CASE WHEN type = 2 AND status = 2 THEN amount ELSE 0 END), 0) as withdraw " + + "FROM order_fund WHERE create_time >= #{startTime} AND create_time < #{endTime} " + + "GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month") + List> sumMonthlyFundFlow(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); } diff --git a/src/main/java/com/it/rattan/monisuo/mapper/OrderTradeMapper.java b/src/main/java/com/it/rattan/monisuo/mapper/OrderTradeMapper.java index d1092ac..8d322b3 100644 --- a/src/main/java/com/it/rattan/monisuo/mapper/OrderTradeMapper.java +++ b/src/main/java/com/it/rattan/monisuo/mapper/OrderTradeMapper.java @@ -12,46 +12,57 @@ import java.util.Map; /** * 交易订单Mapper + * + * 修复:order_trade 表的列名是 direction(不是 type),且没有 coin_name 列 + * 优化:添加 GROUP BY 批量查询替代循环查询 */ @Mapper public interface OrderTradeMapper extends BaseMapper { - // ========== 分析相关查询 ========== + // ========== 基础统计查询 ========== /** - * 指定类型和时间段内的交易金额 + * 指定时间段内的手续费总额(status=1 表示成功成交,手续费率 0.1%) */ - @Select("SELECT IFNULL(SUM(amount), 0) FROM order_trade WHERE type = #{type} AND create_time >= #{startTime}") - BigDecimal sumAmountByTypeAndTime(@Param("type") int type, @Param("startTime") LocalDateTime startTime); + @Select("SELECT IFNULL(SUM(amount * 0.001), 0) FROM order_trade WHERE status = 1 AND create_time >= #{startTime}") + BigDecimal sumFeeByTime(@Param("startTime") LocalDateTime startTime); /** - * 指定类型和时间段内的交易笔数 + * 按方向和时间统计交易金额 */ - @Select("SELECT COUNT(*) FROM order_trade WHERE type = #{type} AND create_time >= #{startTime}") - int countByTypeAndTime(@Param("type") int type, @Param("startTime") LocalDateTime startTime); + @Select("SELECT IFNULL(SUM(amount), 0) FROM order_trade WHERE direction = #{direction} AND status = 1 AND create_time >= #{startTime}") + BigDecimal sumAmountByDirectionAndTime(@Param("direction") int direction, @Param("startTime") LocalDateTime startTime); /** - * 指定类型和时间范围内的交易金额 + * 按方向和时间统计交易笔数 */ - @Select("SELECT IFNULL(SUM(amount), 0) FROM order_trade WHERE type = #{type} AND create_time >= #{startTime} AND create_time < #{endTime}") - BigDecimal sumAmountByTypeAndTimeRange(@Param("type") int type, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + @Select("SELECT COUNT(*) FROM order_trade WHERE direction = #{direction} AND status = 1 AND create_time >= #{startTime}") + int countByDirectionAndTime(@Param("direction") int direction, @Param("startTime") LocalDateTime startTime); /** - * 指定时间段内的活跃用户数 + * 指定时间段内的活跃交易用户数 */ @Select("SELECT COUNT(DISTINCT user_id) FROM order_trade WHERE create_time >= #{startTime} AND create_time < #{endTime}") int countDistinctUserByTime(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); - /** - * 按币种分组统计交易金额 - */ - @Select("SELECT coin_code as coinCode, coin_name as coinName, SUM(amount) as amount FROM order_trade " + - "WHERE create_time >= #{startTime} GROUP BY coin_code, coin_name ORDER BY amount DESC") - List> sumAmountGroupByCoin(@Param("startTime") LocalDateTime startTime); + // ========== GROUP BY 批量查询(替代循环查询) ========== /** - * 指定时间段内的手续费总额(假设手续费率为0.1%) + * 按天分组统计买入/卖出金额(替代循环 N×2 次查询) */ - @Select("SELECT IFNULL(SUM(amount * 0.001), 0) FROM order_trade WHERE status = 2 AND create_time >= #{startTime}") - BigDecimal sumFeeByTime(@Param("startTime") LocalDateTime startTime); + @Select("SELECT DATE(create_time) as date, " + + "IFNULL(SUM(CASE WHEN direction = 1 AND status = 1 THEN amount ELSE 0 END), 0) as buy, " + + "IFNULL(SUM(CASE WHEN direction = 2 AND status = 1 THEN amount ELSE 0 END), 0) as sell " + + "FROM order_trade WHERE create_time >= #{startTime} AND create_time < #{endTime} " + + "GROUP BY DATE(create_time) ORDER BY date") + List> sumDailyTradeAmount(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + /** + * 按币种分组统计交易金额(修复:使用 JOIN coin 表获取名称) + */ + @Select("SELECT ot.coin_code as coinCode, c.name as coinName, IFNULL(SUM(ot.amount), 0) as amount " + + "FROM order_trade ot LEFT JOIN coin c ON ot.coin_code = c.code " + + "WHERE ot.status = 1 AND ot.create_time >= #{startTime} " + + "GROUP BY ot.coin_code, c.name ORDER BY amount DESC") + List> sumAmountGroupByCoin(@Param("startTime") LocalDateTime startTime); } diff --git a/src/main/java/com/it/rattan/monisuo/service/AssetService.java b/src/main/java/com/it/rattan/monisuo/service/AssetService.java index 93fc5f0..ced97bf 100644 --- a/src/main/java/com/it/rattan/monisuo/service/AssetService.java +++ b/src/main/java/com/it/rattan/monisuo/service/AssetService.java @@ -20,9 +20,14 @@ import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.*; +import java.util.stream.Collectors; /** - * 资产服务 + * 资产服务 - 性能优化版 + * + * 优化点: + * 1. 批量加载币种数据,消除 N+1 查询 + * 2. getDailyProfit 使用只读查询,避免误创建空持仓记录 */ @Service public class AssetService { @@ -43,7 +48,7 @@ public class AssetService { private CoinService coinService; /** - * 获取资产总览 + * 获取资产总览(优化:批量加载币种,消除 N+1) */ public Map getOverview(Long userId) { Map result = new HashMap<>(); @@ -55,38 +60,38 @@ public class AssetService { // 交易账户 BigDecimal tradeBalance = BigDecimal.ZERO; - BigDecimal totalCost = BigDecimal.ZERO; // 累计成本 - BigDecimal totalValue = BigDecimal.ZERO; // 当前价值 + BigDecimal totalCost = BigDecimal.ZERO; + BigDecimal totalValue = BigDecimal.ZERO; LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(AccountTrade::getUserId, userId) .gt(AccountTrade::getQuantity, BigDecimal.ZERO); List trades = accountTradeMapper.selectList(wrapper); - for (AccountTrade trade : trades) { - Coin coin = coinService.getCoinByCode(trade.getCoinCode()); - if (coin != null) { - BigDecimal value = trade.getQuantity().multiply(coin.getPrice()) - .setScale(8, RoundingMode.DOWN); - tradeBalance = tradeBalance.add(value); + if (!trades.isEmpty()) { + // 批量获取币种数据(一次查询替代 N 次查询) + List coinCodes = trades.stream() + .map(AccountTrade::getCoinCode) + .distinct() + .collect(Collectors.toList()); + Map coinMap = coinService.getCoinMapByCodes(coinCodes); - // 计算成本和盈亏 - BigDecimal cost = trade.getQuantity().multiply(trade.getAvgPrice()); - totalCost = totalCost.add(cost); - totalValue = totalValue.add(value); + for (AccountTrade trade : trades) { + Coin coin = coinMap.get(trade.getCoinCode().toUpperCase()); + if (coin != null) { + BigDecimal value = trade.getQuantity().multiply(coin.getPrice()) + .setScale(8, RoundingMode.DOWN); + tradeBalance = tradeBalance.add(value); + BigDecimal cost = trade.getQuantity().multiply(trade.getAvgPrice()); + totalCost = totalCost.add(cost); + totalValue = totalValue.add(value); + } } } result.put("tradeBalance", tradeBalance); - - // 总资产 - BigDecimal totalAsset = fundBalance.add(tradeBalance); - result.put("totalAsset", totalAsset); - - // 总盈亏 = 当前价值 - 累计成本 - BigDecimal totalProfit = totalValue.subtract(totalCost); - result.put("totalProfit", totalProfit); - + result.put("totalAsset", fundBalance.add(tradeBalance)); + result.put("totalProfit", totalValue.subtract(totalCost)); return result; } @@ -108,12 +113,11 @@ public class AssetService { fund.setCreateTime(LocalDateTime.now()); accountFundMapper.insert(fund); } - return fund; } /** - * 获取交易账户 + * 获取交易账户(优化:批量加载币种) */ public List> getTradeAccount(Long userId) { List> result = new ArrayList<>(); @@ -123,25 +127,32 @@ public class AssetService { .gt(AccountTrade::getQuantity, BigDecimal.ZERO); List trades = accountTradeMapper.selectList(wrapper); - for (AccountTrade trade : trades) { - Coin coin = coinService.getCoinByCode(trade.getCoinCode()); - if (coin != null) { - BigDecimal value = trade.getQuantity().multiply(coin.getPrice()) - .setScale(8, RoundingMode.DOWN); + if (!trades.isEmpty()) { + // 批量获取币种数据 + List coinCodes = trades.stream() + .map(AccountTrade::getCoinCode) + .distinct() + .collect(Collectors.toList()); + Map coinMap = coinService.getCoinMapByCodes(coinCodes); - Map item = new HashMap<>(); - item.put("coinCode", trade.getCoinCode()); - item.put("coinName", coin.getName()); - item.put("coinIcon", coin.getIcon()); - item.put("quantity", trade.getQuantity()); - item.put("price", coin.getPrice()); - item.put("value", value); - item.put("avgPrice", trade.getAvgPrice()); - item.put("change24h", coin.getChange24h()); - result.add(item); + for (AccountTrade trade : trades) { + Coin coin = coinMap.get(trade.getCoinCode().toUpperCase()); + if (coin != null) { + BigDecimal value = trade.getQuantity().multiply(coin.getPrice()) + .setScale(8, RoundingMode.DOWN); + Map item = new HashMap<>(); + item.put("coinCode", trade.getCoinCode()); + item.put("coinName", coin.getName()); + item.put("coinIcon", coin.getIcon()); + item.put("quantity", trade.getQuantity()); + item.put("price", coin.getPrice()); + item.put("value", value); + item.put("avgPrice", trade.getAvgPrice()); + item.put("change24h", coin.getChange24h()); + result.add(item); + } } } - return result; } @@ -155,77 +166,60 @@ public class AssetService { } AccountFund fund = getOrCreateFundAccount(userId); - - // 获取交易账户USDT持仓 AccountTrade tradeUsdt = getOrCreateTradeAccount(userId, "USDT"); - LocalDateTime now = LocalDateTime.now(); if (direction == 1) { - // 资金账户 -> 交易账户 if (fund.getBalance().compareTo(amount) < 0) { throw new RuntimeException("资金账户余额不足"); } BigDecimal fundBalanceBefore = fund.getBalance(); - BigDecimal tradeBalanceBefore = tradeUsdt.getQuantity(); fund.setBalance(fund.getBalance().subtract(amount)); tradeUsdt.setQuantity(tradeUsdt.getQuantity().add(amount)); - // 使用 LambdaUpdateWrapper 显式更新资金账户 LambdaUpdateWrapper fundUpdateWrapper = new LambdaUpdateWrapper<>(); fundUpdateWrapper.eq(AccountFund::getId, fund.getId()) .set(AccountFund::getBalance, fund.getBalance()) .set(AccountFund::getUpdateTime, now); accountFundMapper.update(null, fundUpdateWrapper); - // 使用 LambdaUpdateWrapper 显式更新交易账户 LambdaUpdateWrapper tradeUpdateWrapper = new LambdaUpdateWrapper<>(); tradeUpdateWrapper.eq(AccountTrade::getId, tradeUsdt.getId()) .set(AccountTrade::getQuantity, tradeUsdt.getQuantity()) .set(AccountTrade::getUpdateTime, now); accountTradeMapper.update(null, tradeUpdateWrapper); - // 记录流水 createFlow(userId, 4, amount.negate(), fundBalanceBefore, fund.getBalance(), "USDT", null, "划转至交易账户"); } else if (direction == 2) { - // 交易账户 -> 资金账户 if (tradeUsdt.getQuantity().compareTo(amount) < 0) { throw new RuntimeException("交易账户USDT余额不足"); } BigDecimal fundBalanceBefore = fund.getBalance(); - BigDecimal tradeBalanceBefore = tradeUsdt.getQuantity(); tradeUsdt.setQuantity(tradeUsdt.getQuantity().subtract(amount)); fund.setBalance(fund.getBalance().add(amount)); - // 使用 LambdaUpdateWrapper 显式更新资金账户 LambdaUpdateWrapper fundUpdateWrapper = new LambdaUpdateWrapper<>(); fundUpdateWrapper.eq(AccountFund::getId, fund.getId()) .set(AccountFund::getBalance, fund.getBalance()) .set(AccountFund::getUpdateTime, now); accountFundMapper.update(null, fundUpdateWrapper); - // 使用 LambdaUpdateWrapper 显式更新交易账户 LambdaUpdateWrapper tradeUpdateWrapper = new LambdaUpdateWrapper<>(); tradeUpdateWrapper.eq(AccountTrade::getId, tradeUsdt.getId()) .set(AccountTrade::getQuantity, tradeUsdt.getQuantity()) .set(AccountTrade::getUpdateTime, now); accountTradeMapper.update(null, tradeUpdateWrapper); - // 记录流水 createFlow(userId, 3, amount, fundBalanceBefore, fund.getBalance(), "USDT", null, "划转至资金账户"); } else { throw new RuntimeException("无效的划转方向"); } - - System.out.println("[划转成功] 用户ID=" + userId + ", 方向=" + (direction == 1 ? "资金→交易" : "交易→资金") + - ", 金额=" + amount + " USDT, 资金账户余额=" + fund.getBalance() + - ", 交易账户USDT=" + tradeUsdt.getQuantity()); } /** @@ -243,14 +237,12 @@ public class AssetService { trade.setCoinCode(coinCode.toUpperCase()); trade.setQuantity(BigDecimal.ZERO); trade.setFrozen(BigDecimal.ZERO); - // USDT作为基准货币,均价固定为1 trade.setAvgPrice("USDT".equals(coinCode.toUpperCase()) ? BigDecimal.ONE : BigDecimal.ZERO); trade.setTotalBuy(BigDecimal.ZERO); trade.setTotalSell(BigDecimal.ZERO); trade.setCreateTime(LocalDateTime.now()); accountTradeMapper.insert(trade); } - return trade; } @@ -296,7 +288,11 @@ public class AssetService { } /** - * 获取每日盈亏数据 + * 获取每日盈亏数据(优化版) + * + * 优化点: + * 1. 使用只读查询替代 getOrCreateTradeAccount(),避免在读取操作中写入空持仓记录 + * 2. 批量加载币种数据,消除 N+1 查询 */ public Map getDailyProfit(Long userId, int year, int month) { Map result = new HashMap<>(); @@ -305,7 +301,7 @@ public class AssetService { LocalDate start = LocalDate.of(year, month, 1); LocalDate end = start.withDayOfMonth(start.lengthOfMonth()); - // 查询该月所有已完成的卖出订单 + // 1. 查询该月所有已完成的卖出订单(一次查询) LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(OrderTrade::getUserId, userId) .eq(OrderTrade::getDirection, 2) @@ -314,34 +310,61 @@ public class AssetService { .lt(OrderTrade::getCreateTime, end.plusDays(1).atStartOfDay()); List sellOrders = orderTradeMapper.selectList(wrapper); - // 按日期分组计算每日已实现盈亏 - for (OrderTrade order : sellOrders) { - String dateKey = order.getCreateTime().toLocalDate().toString(); - AccountTrade account = getOrCreateTradeAccount(userId, order.getCoinCode()); - BigDecimal cost = order.getQuantity().multiply(account.getAvgPrice()); - BigDecimal profit = order.getAmount().subtract(cost); - dailyMap.merge(dateKey, profit, BigDecimal::add); + if (!sellOrders.isEmpty()) { + // 批量获取涉及的币种持仓信息(只读查询,不创建) + List coinCodes = sellOrders.stream() + .map(OrderTrade::getCoinCode) + .distinct() + .collect(Collectors.toList()); + + Map tradeMap = new HashMap<>(); + LambdaQueryWrapper tradeWrapper = new LambdaQueryWrapper<>(); + tradeWrapper.eq(AccountTrade::getUserId, userId) + .in(AccountTrade::getCoinCode, coinCodes); + List tradeAccounts = accountTradeMapper.selectList(tradeWrapper); + for (AccountTrade at : tradeAccounts) { + tradeMap.put(at.getCoinCode(), at); + } + + // 按日期分组计算每日已实现盈亏 + for (OrderTrade order : sellOrders) { + String dateKey = order.getCreateTime().toLocalDate().toString(); + AccountTrade account = tradeMap.get(order.getCoinCode()); + if (account != null) { + BigDecimal cost = order.getQuantity().multiply(account.getAvgPrice()); + BigDecimal profit = order.getAmount().subtract(cost); + dailyMap.merge(dateKey, profit, BigDecimal::add); + } + } } - // 今日额外加上未实现盈亏 + // 2. 今日未实现盈亏(批量加载币种) LocalDate today = LocalDate.now(); if (!today.isBefore(start) && !today.isAfter(end)) { - BigDecimal unrealized = BigDecimal.ZERO; LambdaQueryWrapper tradeWrapper = new LambdaQueryWrapper<>(); tradeWrapper.eq(AccountTrade::getUserId, userId) .gt(AccountTrade::getQuantity, BigDecimal.ZERO); List trades = accountTradeMapper.selectList(tradeWrapper); - for (AccountTrade trade : trades) { - Coin coin = coinService.getCoinByCode(trade.getCoinCode()); - if (coin != null) { - BigDecimal value = trade.getQuantity().multiply(coin.getPrice()); - BigDecimal cost = trade.getQuantity().multiply(trade.getAvgPrice()); - unrealized = unrealized.add(value.subtract(cost)); + if (!trades.isEmpty()) { + List coinCodes = trades.stream() + .map(AccountTrade::getCoinCode) + .distinct() + .collect(Collectors.toList()); + Map coinMap = coinService.getCoinMapByCodes(coinCodes); + + BigDecimal unrealized = BigDecimal.ZERO; + for (AccountTrade trade : trades) { + Coin coin = coinMap.get(trade.getCoinCode().toUpperCase()); + if (coin != null) { + BigDecimal value = trade.getQuantity().multiply(coin.getPrice()); + BigDecimal cost = trade.getQuantity().multiply(trade.getAvgPrice()); + unrealized = unrealized.add(value.subtract(cost)); + } } + String todayKey = today.toString(); + dailyMap.merge(todayKey, unrealized, BigDecimal::add); } - String todayKey = today.toString(); - dailyMap.merge(todayKey, unrealized, BigDecimal::add); } // 计算月度总盈亏 diff --git a/src/main/java/com/it/rattan/monisuo/service/CoinService.java b/src/main/java/com/it/rattan/monisuo/service/CoinService.java index d3f1b32..38d7539 100644 --- a/src/main/java/com/it/rattan/monisuo/service/CoinService.java +++ b/src/main/java/com/it/rattan/monisuo/service/CoinService.java @@ -7,39 +7,108 @@ import com.it.rattan.monisuo.mapper.CoinMapper; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; /** - * 币种服务 + * 币种服务 - 带内存缓存 */ @Service public class CoinService extends ServiceImpl { + /** 缓存过期时间(毫秒):30秒 */ + private static final long CACHE_TTL = 30_000; + + /** 币种列表缓存 */ + private volatile List cachedActiveCoins; + private volatile long activeCoinsCacheTime = 0; + + /** 单个币种缓存 */ + private final Map coinCodeCache = new ConcurrentHashMap<>(); + private final Map coinCodeCacheTime = new ConcurrentHashMap<>(); + /** - * 获取所有上架币种 + * 获取所有上架币种(带缓存) */ public List getActiveCoins() { + long now = System.currentTimeMillis(); + if (cachedActiveCoins != null && (now - activeCoinsCacheTime) < CACHE_TTL) { + return cachedActiveCoins; + } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Coin::getStatus, 1) .orderByDesc(Coin::getSort); - return list(wrapper); + cachedActiveCoins = list(wrapper); + activeCoinsCacheTime = now; + return cachedActiveCoins; } /** - * 根据代码获取币种 + * 根据代码获取币种(带缓存) */ public Coin getCoinByCode(String code) { + String key = code.toUpperCase(); + long now = System.currentTimeMillis(); + Long cacheTime = coinCodeCacheTime.get(key); + if (cacheTime != null && (now - cacheTime) < CACHE_TTL) { + return coinCodeCache.get(key); + } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(Coin::getCode, code.toUpperCase()) + wrapper.eq(Coin::getCode, key) .eq(Coin::getStatus, 1); - return getOne(wrapper); + Coin coin = getOne(wrapper); + coinCodeCache.put(key, coin); + coinCodeCacheTime.put(key, now); + return coin; } /** - * 更新币种价格 - * 注意:USDT价格固定为1,不允许修改 + * 批量获取币种(一次查询,返回 Map) + * 用于解决 N+1 查询问题 + */ + public Map getCoinMapByCodes(List codes) { + if (codes == null || codes.isEmpty()) { + return new java.util.HashMap<>(); + } + List upperCodes = codes.stream() + .map(String::toUpperCase) + .collect(Collectors.toList()); + + // 先从缓存中取 + Map result = new ConcurrentHashMap<>(); + List missedCodes = new java.util.ArrayList<>(); + long now = System.currentTimeMillis(); + + for (String code : upperCodes) { + Long cacheTime = coinCodeCacheTime.get(code); + if (cacheTime != null && (now - cacheTime) < CACHE_TTL && coinCodeCache.get(code) != null) { + result.put(code, coinCodeCache.get(code)); + } else { + missedCodes.add(code); + } + } + + // 批量查询缺失的 + if (!missedCodes.isEmpty()) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(Coin::getCode, missedCodes) + .eq(Coin::getStatus, 1); + List coins = list(wrapper); + for (Coin coin : coins) { + String code = coin.getCode().toUpperCase(); + result.put(code, coin); + coinCodeCache.put(code, coin); + coinCodeCacheTime.put(code, now); + } + } + return result; + } + + /** + * 更新币种价格(同时清除缓存) */ public void updatePrice(String code, BigDecimal price) { - // USDT价格固定为1,不允许修改 if ("USDT".equalsIgnoreCase(code)) { return; } @@ -48,6 +117,8 @@ public class CoinService extends ServiceImpl { coin.setPrice(price); coin.setUpdateTime(java.time.LocalDateTime.now()); updateById(coin); + // 清除该币种的缓存 + clearCache(code); } } @@ -60,4 +131,24 @@ public class CoinService extends ServiceImpl { .eq(Coin::getStatus, 1); return list(wrapper); } + + /** + * 清除所有缓存(币种数据变更时调用) + */ + public void clearCache(String code) { + if (code != null) { + String key = code.toUpperCase(); + coinCodeCache.remove(key); + coinCodeCacheTime.remove(key); + } + cachedActiveCoins = null; + activeCoinsCacheTime = 0; + } + + public void clearAllCache() { + cachedActiveCoins = null; + activeCoinsCacheTime = 0; + coinCodeCache.clear(); + coinCodeCacheTime.clear(); + } } diff --git a/src/main/java/com/it/rattan/monisuo/service/FundService.java b/src/main/java/com/it/rattan/monisuo/service/FundService.java index 7aa9ac3..51cb43d 100644 --- a/src/main/java/com/it/rattan/monisuo/service/FundService.java +++ b/src/main/java/com/it/rattan/monisuo/service/FundService.java @@ -379,29 +379,14 @@ public class FundService { throw new RuntimeException("充值审批更新账户余额失败"); } - // 验证更新结果 - 使用新的查询确保从数据库读取 - AccountFund verifyFund = accountFundMapper.selectById(fund.getId()); - System.out.println(" - 验证更新后余额: " + verifyFund.getBalance()); - System.out.println(" - 验证更新后累计充值: " + verifyFund.getTotalDeposit()); - - if (verifyFund.getBalance() == null || !verifyFund.getBalance().equals(newBalance)) { - System.err.println("[FundService.approve] 严重错误: 账户余额更新验证失败!"); - System.err.println(" - 期望余额: " + newBalance); - System.err.println(" - 实际余额: " + verifyFund.getBalance()); - throw new RuntimeException("账户余额更新验证失败"); - } - System.out.println(" - 余额验证通过 ✓"); - - // 更新本地对象状态 + // 更新本地对象状态(直接信任 update 返回值,避免额外的 selectById 查询) fund.setBalance(newBalance); fund.setTotalDeposit(newTotalDeposit); fund.setUpdateTime(updateTime); // 记录流水 - System.out.println("[FundService.approve] 创建资金流水记录..."); assetService.createFlow(order.getUserId(), 1, order.getAmount(), balanceBefore, newBalance, "USDT", orderNo, "充值"); - System.out.println(" - 流水记录创建成功"); System.out.println("[充值审批成功] 订单号: " + orderNo + ", 用户ID: " + order.getUserId() + ", 充值金额: " + order.getAmount() + " USDT"); @@ -443,21 +428,14 @@ public class FundService { throw new RuntimeException("提现审批更新账户冻结失败"); } - // 验证更新结果 - AccountFund verifyFund = accountFundMapper.selectById(fund.getId()); - System.out.println(" - 验证更新后冻结: " + verifyFund.getFrozen()); - System.out.println(" - 验证更新后累计提现: " + verifyFund.getTotalWithdraw()); - // 更新本地对象状态 fund.setFrozen(newFrozen); fund.setTotalWithdraw(newTotalWithdraw); fund.setUpdateTime(updateTime); // 记录流水 (负数表示支出) - System.out.println("[FundService.approve] 创建资金流水记录..."); assetService.createFlow(order.getUserId(), 2, order.getAmount().negate(), balanceBefore, balanceBefore, "USDT", orderNo, "提现"); - System.out.println(" - 流水记录创建成功"); System.out.println("[提现审批成功] 订单号: " + orderNo + ", 用户ID: " + order.getUserId() + ", 提现金额: " + order.getAmount() + " USDT"); @@ -505,21 +483,14 @@ public class FundService { throw new RuntimeException("提现驳回更新账户失败"); } - // 验证更新结果 - AccountFund verifyFund = accountFundMapper.selectById(fund.getId()); - System.out.println(" - 验证更新后余额: " + verifyFund.getBalance()); - System.out.println(" - 验证更新后冻结: " + verifyFund.getFrozen()); - // 更新本地对象状态 fund.setBalance(newBalance); fund.setFrozen(newFrozen); fund.setUpdateTime(updateTime); // 记录流水 - System.out.println("[FundService.approve] 创建资金流水记录..."); assetService.createFlow(order.getUserId(), 2, order.getAmount(), balanceBefore, newBalance, "USDT", orderNo, "提现驳回退还"); - System.out.println(" - 流水记录创建成功"); System.out.println("[提现驳回成功] 订单号: " + orderNo + ", 用户ID: " + order.getUserId() + ", 退还金额: " + order.getAmount() + " USDT"); @@ -552,61 +523,14 @@ public class FundService { System.out.println(" - UPDATE SQL 将更新: status=" + finalStatus + ", approveAdminId=" + adminId); int orderUpdateResult = orderFundMapper.update(null, updateWrapper); - System.out.println(" - 订单更新结果: " + orderUpdateResult + " (1=成功, 0=失败)"); - // 验证更新是否成功 - 使用新的查询确保从数据库读取 - if (orderUpdateResult > 0) { - System.out.println("[FundService.approve] 步骤6: 验证更新结果..."); - - // 清除可能的缓存,强制从数据库读取 - OrderFund verifyOrder = orderFundMapper.selectOne( - new LambdaQueryWrapper() - .eq(OrderFund::getId, order.getId()) - .select(OrderFund::getId, OrderFund::getOrderNo, OrderFund::getStatus, - OrderFund::getApproveAdminId, OrderFund::getApproveTime)); - - System.out.println(" - 验证查询结果: ID=" + verifyOrder.getId() + - ", 订单号=" + verifyOrder.getOrderNo() + - ", 状态=" + verifyOrder.getStatus()); - - if (!verifyOrder.getStatus().equals(finalStatus)) { - System.err.println("[FundService.approve] 严重错误: 订单状态更新后验证失败!"); - System.err.println(" - 期望状态: " + finalStatus); - System.err.println(" - 实际状态: " + verifyOrder.getStatus()); - throw new RuntimeException("订单状态更新失败,请检查数据库配置"); - } else { - System.out.println(" - 状态验证通过 ✓"); - } - } else { - System.err.println("[FundService.approve] 订单更新失败! update返回: " + orderUpdateResult); + if (orderUpdateResult <= 0) { throw new RuntimeException("订单更新失败"); } - // 最终验证:确保账户余额正确更新(仅在审批通过时) - if (status == 2) { - System.out.println("[FundService.approve] 步骤7: 最终验证账户余额..."); - AccountFund finalVerifyFund = accountFundMapper.selectById(fund.getId()); - System.out.println(" - 最终账户余额: " + finalVerifyFund.getBalance()); - System.out.println(" - 最终累计充值: " + finalVerifyFund.getTotalDeposit()); - - if (order.getType() == 1) { - // 充值订单:验证余额是否正确增加 - BigDecimal expectedBalance = fund.getBalance(); - if (finalVerifyFund.getBalance() == null || - finalVerifyFund.getBalance().compareTo(expectedBalance) != 0) { - System.err.println("[FundService.approve] 严重错误: 最终验证发现账户余额不一致!"); - System.err.println(" - 期望余额: " + expectedBalance); - System.err.println(" - 实际余额: " + finalVerifyFund.getBalance()); - throw new RuntimeException("账户余额最终验证失败,数据可能不一致"); - } - System.out.println(" - 账户余额最终验证通过 ✓"); - } - } - System.out.println("[审批完成] 订单号: " + orderNo + ", 订单类型: " + (order.getType() == 1 ? "充值" : "提现") + ", 审批结果: " + (status == 2 ? "通过" : "驳回") + ", 最终状态: " + finalStatus + ", 审批人: " + adminName); - System.out.println("[FundService.approve] 处理完成\n"); } /** diff --git a/src/main/java/com/it/rattan/monisuo/service/UserService.java b/src/main/java/com/it/rattan/monisuo/service/UserService.java index d61ec3c..55a01b1 100644 --- a/src/main/java/com/it/rattan/monisuo/service/UserService.java +++ b/src/main/java/com/it/rattan/monisuo/service/UserService.java @@ -12,6 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.IOException; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; @@ -122,20 +125,52 @@ public class UserService extends ServiceImpl { } /** - * 上传KYC资料 + * 上传KYC资料(身份证正反面图片) */ @Transactional - public void uploadKyc(Long userId, String idCardFront, String idCardBack) { + public void uploadKyc(Long userId, MultipartFile frontFile, MultipartFile backFile) { User user = userMapper.selectById(userId); if (user == null) { throw new RuntimeException("用户不存在"); } - user.setIdCardFront(idCardFront); - user.setIdCardBack(idCardBack); - user.setKycStatus(1); - user.setUpdateTime(LocalDateTime.now()); - userMapper.updateById(user); + try { + // 上传目录:项目工作目录下的 uploads/kyc/ + String uploadDir = System.getProperty("user.dir") + File.separator + "uploads" + File.separator + "kyc"; + File dir = new File(uploadDir); + if (!dir.exists()) { + dir.mkdirs(); + } + + String timestamp = String.valueOf(System.currentTimeMillis()); + String frontPath = saveFile(frontFile, dir, userId + "_front_" + timestamp); + String backPath = saveFile(backFile, dir, userId + "_back_" + timestamp); + + // 存储相对访问路径 + user.setIdCardFront("/uploads/kyc/" + frontPath); + user.setIdCardBack("/uploads/kyc/" + backPath); + user.setKycStatus(2); // 虚拟KYC:提交即自动通过 + user.setUpdateTime(LocalDateTime.now()); + userMapper.updateById(user); + } catch (IOException e) { + throw new RuntimeException("文件保存失败: " + e.getMessage()); + } + } + + /** + * 保存上传文件,返回保存的文件名 + */ + private String saveFile(MultipartFile file, File dir, String baseName) throws IOException { + String originalName = file.getOriginalFilename(); + String ext = ""; + if (originalName != null && originalName.contains(".")) { + ext = originalName.substring(originalName.lastIndexOf(".")); + } else { + ext = ".jpg"; + } + String fileName = baseName + ext; + file.transferTo(new File(dir, fileName)); + return fileName; } /** diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a146a88..d68b2bf 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,6 +2,11 @@ server: port: 5010 spring: + servlet: + multipart: + enabled: true + max-file-size: 5MB + max-request-size: 10MB datasource: username: monisuo password: JPJ8wYicSGC8aRnk diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml index e0eb602..b607889 100644 --- a/src/main/resources/application-prd.yml +++ b/src/main/resources/application-prd.yml @@ -2,6 +2,11 @@ server: port: 9010 spring: + servlet: + multipart: + enabled: true + max-file-size: 5MB + max-request-size: 10MB datasource: username: root password: 897admin$$