Compare commits
2 Commits
e26031ad17
...
08623d7a87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08623d7a87 | ||
|
|
2a901de2c3 |
Binary file not shown.
@@ -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"}]}
|
||||
@@ -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":[]}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下半区列表项
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -31,6 +31,9 @@ dependencies:
|
||||
intl: ^0.20.2
|
||||
decimal: ^2.3.3
|
||||
|
||||
# 图片选择
|
||||
image_picker: ^1.0.7
|
||||
|
||||
# 字体
|
||||
google_fonts: ^6.2.1
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨域过滤器 - 支持凭证,最高优先级
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -26,6 +26,7 @@ public class TokenFilter implements Filter {
|
||||
"/api/user/login",
|
||||
"/api/wallet/default",
|
||||
"/admin/login",
|
||||
"/uploads/",
|
||||
"/swagger-resources",
|
||||
"/v2/api-docs",
|
||||
"/webjars/",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 计算月度总盈亏
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$$
|
||||
|
||||
Reference in New Issue
Block a user