Compare commits

...

2 Commits

Author SHA1 Message Date
sion
08623d7a87 Merge remote-tracking branch 'origin/main' 2026-03-30 00:31:05 +08:00
sion
2a901de2c3 111 2026-03-30 00:30:42 +08:00
27 changed files with 1324 additions and 650 deletions

View File

@@ -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"}]}
{"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"}]}

View File

@@ -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":[]}
{"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":[]}

View File

@@ -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<T> {
bool get isSuccess => success;
bool get isUnauthorized => code == ResponseCode.unauthorized;
bool get isKycRequired => code == ResponseCode.kycRequired;
}

View File

@@ -68,6 +68,24 @@ class DioClient {
}
}
/// Multipart 文件上传
Future<ApiResponse<T>> upload<T>(
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<T>(response, fromJson);
} on DioException catch (e) {
return _handleError<T>(e);
}
}
ApiResponse<T> _handleResponse<T>(
Response response,
T Function(dynamic)? fromJson,

View File

@@ -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<ApiResponse<void>> uploadKyc(
String idCardFront,
String idCardBack,
Uint8List frontBytes,
Uint8List backBytes,
) async {
return _client.post<void>(
final formData = FormData.fromMap({
'front': MultipartFile.fromBytes(frontBytes, filename: 'front.jpg'),
'back': MultipartFile.fromBytes(backBytes, filename: 'back.jpg'),
});
return _client.upload<void>(
ApiEndpoints.kyc,
data: {'idCardFront': idCardFront, 'idCardBack': idCardBack},
formData: formData,
);
}

View File

@@ -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<ApiResponse<void>> 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();

View File

@@ -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<AuthProvider>();
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,
),
),
],
),
],
),
),
),
);
}

View File

@@ -88,11 +88,11 @@ class _HomePageState extends State<HomePage>
),
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,
),
),
),
],
),
),
),
);

View File

@@ -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<MainPageState>();
mainState?.switchToTrade(coin.code);
}
}
/// 下半区列表项

View File

@@ -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<MinePage> 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<Widget> _buildMenuItems(ColorScheme colorScheme) {
List<Widget> _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;

View File

@@ -84,10 +84,8 @@ class _TradePageState extends State<TradePage>
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<TradePage>
_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<TradePage>
if (_selectedCoin != null)
_PriceCard(coin: _selectedCoin!)
else
_PlaceholderCard(message: '请先选择交易币种'),
const _PlaceholderCard(message: '请先选择交易币种'),
SizedBox(height: AppSpacing.md),
@@ -197,7 +198,13 @@ class _TradePageState extends State<TradePage>
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),
),
],
),
),
],
);
}
}

View File

@@ -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:

View File

@@ -31,6 +31,9 @@ dependencies:
intl: ^0.20.2
decimal: ^2.3.3
# 图片选择
image_picker: ^1.0.7
# 字体
google_fonts: ^6.2.1

View File

@@ -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);
}
/**
* 跨域过滤器 - 支持凭证,最高优先级
*/

View File

@@ -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<Map<String, Object>> getProfitAnalysis(
@RequestParam(defaultValue = "month") String range) {
Map<String, Object> 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<List<Map<String, Object>>> getCashFlowTrend(
@RequestParam(defaultValue = "6") int months) {
List<Map<String, Object>> 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<Map<String, Object>> dbResults = orderFundMapper.sumMonthlyFundFlow(queryStart, queryEnd);
Map<String, Map<String, Object>> dbMap = dbResults.stream()
.collect(Collectors.toMap(
m -> (String) m.get("month"),
m -> m,
(a, b) -> a
));
// 组装结果(保持原有月份顺序和格式)
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> getTradeAnalysis(
@RequestParam(defaultValue = "week") String range) {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> dailyResults = orderTradeMapper.sumDailyTradeAmount(trendStart, trendEnd);
Map<String, Map<String, Object>> dailyMap = dailyResults.stream()
.collect(Collectors.toMap(
m -> m.get("date").toString(),
m -> m,
(a, b) -> a
));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M-d");
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<List<Map<String, Object>>> getCoinDistribution(
@RequestParam(defaultValue = "month") String range) {
LocalDateTime startTime = getStartTime(range);
List<Map<String, Object>> result = orderTradeMapper.sumAmountGroupByCoin(startTime);
return Result.success(result);
}
/**
* 用户增长分析
* 用户增长分析(优化:减少循环查询次数)
*/
@GetMapping("/user-growth")
public Result<Map<String, Object>> getUserGrowth(
@RequestParam(defaultValue = "6") int months) {
Map<String, Object> data = new HashMap<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M月");
// 月度趋势
List<Map<String, Object>> 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<String, Object> item = new HashMap<>();
item.put("month", monthStart.format(formatter));
// 新增用户
int newUsers = userService.count(new LambdaQueryWrapper<User>()
.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<User>()
.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<Map<String, Object>> getRiskMetrics() {
Map<String, Object> 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<User>()
.eq(User::getKycStatus, 0));
data.put("pendingKyc", pendingKyc);
// 冻结账户
int frozenAccounts = userService.count(new LambdaQueryWrapper<User>()
.eq(User::getStatus, 0));
data.put("frozenAccounts", frozenAccounts);
return Result.success(data);
}
@@ -270,41 +289,36 @@ public class AnalysisController {
@GetMapping("/health")
public Result<Map<String, Object>> getHealthScore() {
Map<String, Object> 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<User>()
.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);
}

View File

@@ -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();

View File

@@ -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<Void> uploadKyc(@RequestBody Map<String, String> params) {
public Result<Void> 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());

View File

@@ -26,6 +26,7 @@ public class TokenFilter implements Filter {
"/api/user/login",
"/api/wallet/default",
"/admin/login",
"/uploads/",
"/swagger-resources",
"/v2/api-docs",
"/webjars/",

View File

@@ -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<OrderFund> {
// ========== 分析相关查询 ==========
/**
* 指定时间段内的手续费总额
* 指定时间段内的手续费总额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<OrderFund> {
@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<Map<String, Object>> sumMonthlyFundFlow(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
}

View File

@@ -12,46 +12,57 @@ import java.util.Map;
/**
* 交易订单Mapper
*
* 修复order_trade 表的列名是 direction不是 type且没有 coin_name 列
* 优化:添加 GROUP BY 批量查询替代循环查询
*/
@Mapper
public interface OrderTradeMapper extends BaseMapper<OrderTrade> {
// ========== 分析相关查询 ==========
// ========== 基础统计查询 ==========
/**
* 指定类型和时间段内的交易金额
* 指定时间段内的手续费总额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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> sumAmountGroupByCoin(@Param("startTime") LocalDateTime startTime);
}

View File

@@ -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<String, Object> getOverview(Long userId) {
Map<String, Object> 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<AccountTrade> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AccountTrade::getUserId, userId)
.gt(AccountTrade::getQuantity, BigDecimal.ZERO);
List<AccountTrade> 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<String> coinCodes = trades.stream()
.map(AccountTrade::getCoinCode)
.distinct()
.collect(Collectors.toList());
Map<String, Coin> 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<Map<String, Object>> getTradeAccount(Long userId) {
List<Map<String, Object>> result = new ArrayList<>();
@@ -123,25 +127,32 @@ public class AssetService {
.gt(AccountTrade::getQuantity, BigDecimal.ZERO);
List<AccountTrade> 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<String> coinCodes = trades.stream()
.map(AccountTrade::getCoinCode)
.distinct()
.collect(Collectors.toList());
Map<String, Coin> coinMap = coinService.getCoinMapByCodes(coinCodes);
Map<String, Object> 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<String, Object> 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<AccountFund> fundUpdateWrapper = new LambdaUpdateWrapper<>();
fundUpdateWrapper.eq(AccountFund::getId, fund.getId())
.set(AccountFund::getBalance, fund.getBalance())
.set(AccountFund::getUpdateTime, now);
accountFundMapper.update(null, fundUpdateWrapper);
// 使用 LambdaUpdateWrapper 显式更新交易账户
LambdaUpdateWrapper<AccountTrade> 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<AccountFund> fundUpdateWrapper = new LambdaUpdateWrapper<>();
fundUpdateWrapper.eq(AccountFund::getId, fund.getId())
.set(AccountFund::getBalance, fund.getBalance())
.set(AccountFund::getUpdateTime, now);
accountFundMapper.update(null, fundUpdateWrapper);
// 使用 LambdaUpdateWrapper 显式更新交易账户
LambdaUpdateWrapper<AccountTrade> 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<String, Object> getDailyProfit(Long userId, int year, int month) {
Map<String, Object> 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<OrderTrade> 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<OrderTrade> 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<String> coinCodes = sellOrders.stream()
.map(OrderTrade::getCoinCode)
.distinct()
.collect(Collectors.toList());
Map<String, AccountTrade> tradeMap = new HashMap<>();
LambdaQueryWrapper<AccountTrade> tradeWrapper = new LambdaQueryWrapper<>();
tradeWrapper.eq(AccountTrade::getUserId, userId)
.in(AccountTrade::getCoinCode, coinCodes);
List<AccountTrade> 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<AccountTrade> tradeWrapper = new LambdaQueryWrapper<>();
tradeWrapper.eq(AccountTrade::getUserId, userId)
.gt(AccountTrade::getQuantity, BigDecimal.ZERO);
List<AccountTrade> 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<String> coinCodes = trades.stream()
.map(AccountTrade::getCoinCode)
.distinct()
.collect(Collectors.toList());
Map<String, Coin> 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);
}
// 计算月度总盈亏

View File

@@ -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<CoinMapper, Coin> {
/** 缓存过期时间毫秒30秒 */
private static final long CACHE_TTL = 30_000;
/** 币种列表缓存 */
private volatile List<Coin> cachedActiveCoins;
private volatile long activeCoinsCacheTime = 0;
/** 单个币种缓存 */
private final Map<String, Coin> coinCodeCache = new ConcurrentHashMap<>();
private final Map<String, Long> coinCodeCacheTime = new ConcurrentHashMap<>();
/**
* 获取所有上架币种
* 获取所有上架币种(带缓存)
*/
public List<Coin> getActiveCoins() {
long now = System.currentTimeMillis();
if (cachedActiveCoins != null && (now - activeCoinsCacheTime) < CACHE_TTL) {
return cachedActiveCoins;
}
LambdaQueryWrapper<Coin> 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<Coin> 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<code, Coin>
* 用于解决 N+1 查询问题
*/
public Map<String, Coin> getCoinMapByCodes(List<String> codes) {
if (codes == null || codes.isEmpty()) {
return new java.util.HashMap<>();
}
List<String> upperCodes = codes.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// 先从缓存中取
Map<String, Coin> result = new ConcurrentHashMap<>();
List<String> 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<Coin> wrapper = new LambdaQueryWrapper<>();
wrapper.in(Coin::getCode, missedCodes)
.eq(Coin::getStatus, 1);
List<Coin> 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<CoinMapper, Coin> {
coin.setPrice(price);
coin.setUpdateTime(java.time.LocalDateTime.now());
updateById(coin);
// 清除该币种的缓存
clearCache(code);
}
}
@@ -60,4 +131,24 @@ public class CoinService extends ServiceImpl<CoinMapper, Coin> {
.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();
}
}

View File

@@ -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<OrderFund>()
.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");
}
/**

View File

@@ -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<UserMapper, User> {
}
/**
* 上传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;
}
/**

View File

@@ -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

View File

@@ -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$$