diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 0000000..d22d9b1 --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,126 @@ +# 用户详情数据面板 - 实现方案 + +## 需求概述 +在管理后台用户管理页面,点击用户详情时展示全面的数据分析视图,包括:充值记录、累计充值/提现、推广人数信息、领取福利信息等。 + +## 方案设计 + +### 一、后端:新增用户详情聚合接口 + +**文件**: `AdminController.java` + +新增 `GET /admin/user/stats?userId=xxx` 接口,一次性返回所有统计数据: + +```json +{ + "code": "0000", + "data": { + "user": { ... }, // 用户基本信息 + "fundAccount": { ... }, // 资金账户(余额、冻结、累计充值、累计提现) + "tradeAccounts": [...], // 交易账户持仓列表 + "depositStats": { // 充值统计 + "totalCount": 5, + "totalAmount": 10000.00, + "successCount": 4, + "successAmount": 8000.00 + }, + "withdrawStats": { // 提现统计 + "totalCount": 3, + "totalAmount": 3000.00, + "successCount": 2, + "successAmount": 2000.00, + "totalFee": 200.00 + }, + "referralStats": { // 推广统计 + "directCount": 5, // 直接推广人数 + "indirectCount": 8, // 间接推广人数 + "referrals": [ // 直接推广人列表 + { "userId": 10, "username": "user1", "nickname": "用户1", "createTime": "...", "deposited": true } + ] + }, + "bonusStats": { // 福利统计 + "newUserBonusClaimed": true, + "totalBonusClaimed": 500.00, + "records": [ // 领取记录 + { "type": "新人首充福利", "amount": 100, "time": "..." }, + { "type": "邀请奖励-10-1", "amount": 100, "time": "..." } + ] + }, + "recentOrders": [...], // 最近10笔充提订单 + "recentTrades": [...] // 最近10笔交易订单 + } +} +``` + +**实现要点**: +- 在 `AssetService` 或新建 `AdminDataService` 中封装聚合查询逻辑 +- 使用已有的 `AccountFundMapper`、`OrderFundMapper`、`AccountFlowMapper`、`OrderTradeMapper` 进行数据查询 +- 推广人列表通过 `UserMapper` 查询 `referredBy = userId` 获取 +- 福利记录通过 `AccountFlow` 查询 `flowType=7` (福利类型) 或通过 `remark` LIKE 匹配福利关键词 + +### 二、前端:用户详情弹窗升级为多标签页 + +**文件**: `monisuo-admin/src/pages/monisuo/users.vue` + +将现有的简单详情弹窗升级为多标签页数据面板: + +#### 弹窗布局 +``` +┌──────────────────────────────────────────────┐ +│ 用户详情 - username │ +├──────────────────────────────────────────────┤ +│ [概览] [充提记录] [推广信息] [福利记录] [交易记录] │ +├──────────────────────────────────────────────┤ +│ │ +│ (各标签页内容区) │ +│ │ +└──────────────────────────────────────────────┘ +``` + +#### 标签页内容设计 + +**1. 概览 Tab** +- 用户基本信息卡片(用户名、昵称、手机、邮箱、注册时间、最后登录) +- KPI 统计卡片行(4列网格): + - 资金账户余额 / 冻结金额 + - 累计充值 / 累计提现 + - 直接推广人数 / 间接推广人数 + - 累计领取福利金额 +- 交易持仓列表(当前持有的币种) + +**2. 充提记录 Tab** +- 充提统计摘要(总充值笔数/金额、总提现笔数/金额、手续费总计) +- 充提订单列表表格(时间、类型、金额、手续费、到账金额、状态) + +**3. 推广信息 Tab** +- 推广概览卡片(推广码、直接推广人数、间接推广人数) +- 直接推广人列表表格(用户名、昵称、注册时间、是否已充值) + +**4. 福利记录 Tab** +- 福利统计摘要(已领取总额、各类型数量) +- 福利领取记录表格(类型、金额、时间) + +**5. 交易记录 Tab** +- 最近交易订单列表(时间、币种、方向、价格、数量、金额) + +### 三、前端 API 层 + +**文件**: `monisuo-admin/src/services/api/monisuo-admin.api.ts` + +新增 `useGetUserStatsQuery(userId)` composable,调用 `/admin/user/stats` 接口。 + +## 实现步骤 + +1. **后端** - 在 `AssetService` 中新增 `getUserStats(userId)` 方法 +2. **后端** - 在 `AdminController` 中新增 `/admin/user/stats` 接口 +3. **前端** - 在 `monisuo-admin.api.ts` 中新增 API composable +4. **前端** - 重构 `users.vue` 详情弹窗为多标签页数据面板 + +## 文件变更清单 + +| 文件 | 操作 | +|------|------| +| `src/.../service/AssetService.java` | 新增 `getUserStats()` 方法 | +| `src/.../controller/AdminController.java` | 新增 `/admin/user/stats` 接口 | +| `monisuo-admin/src/services/api/monisuo-admin.api.ts` | 新增 `useGetUserStatsQuery` | +| `monisuo-admin/src/pages/monisuo/users.vue` | 重构详情弹窗 | diff --git a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill index 01c47ae..4f57452 100644 Binary files a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill and b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill differ diff --git a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache index 8a02581..e8297c0 100644 --- a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache +++ b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/.filecache @@ -1 +1 @@ -{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"d6b4a7aa67aeb750be9e5aec884f1f73"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"03c567345af5a72ca098cfa0a67b3423"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"92b3cbdc74276047205a9d2c62d9222f"},{"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":"d6b4a7aa67aeb750be9e5aec884f1f73"}]} \ No newline at end of file +{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"046ef7e2da888c543069af4aac7edaaf"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"48b58d9876a092c0f3058304ec7c663a"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"b04615af5d7000fefcedb23fde3ace71"},{"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":"046ef7e2da888c543069af4aac7edaaf"}]} \ No newline at end of file diff --git a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json index e6e7f81..874b44c 100644 --- a/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json +++ b/flutter_monisuo/build/9a5d09bec60a9bd952a3f584c1b9bd3b/dart_build_result.json @@ -1 +1 @@ -{"build_start":"2026-04-06T18:39:07.915511","build_end":"2026-04-06T18:39:11.314962","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]} \ No newline at end of file +{"build_start":"2026-04-07T23:07:26.594437","build_end":"2026-04-07T23:07:33.162061","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]} \ No newline at end of file diff --git a/flutter_monisuo/lib/ui/pages/asset/transfer_page.dart b/flutter_monisuo/lib/ui/pages/asset/transfer_page.dart index b5a4fb5..d27e7a3 100644 --- a/flutter_monisuo/lib/ui/pages/asset/transfer_page.dart +++ b/flutter_monisuo/lib/ui/pages/asset/transfer_page.dart @@ -140,9 +140,11 @@ class _TransferPageState extends State { void _setQuickAmount(double percent) { final available = double.tryParse(_availableBalance) ?? 0; final amount = available * percent; - _amountController.text = amount - .toStringAsFixed(8) - .replaceAll(RegExp(r'\.?0+$'), ''); + // 向下截斷到2位小數,避免四捨五入超出餘額 + _amountController.text = + ((amount * 100).truncateToDouble() / 100).toStringAsFixed(2); + + // Trigger haptic feedback HapticFeedback.selectionClick(); } diff --git a/flutter_monisuo/lib/ui/pages/orders/fund_order_card.dart b/flutter_monisuo/lib/ui/pages/orders/fund_order_card.dart index 8df2707..919d58b 100644 --- a/flutter_monisuo/lib/ui/pages/orders/fund_order_card.dart +++ b/flutter_monisuo/lib/ui/pages/orders/fund_order_card.dart @@ -88,6 +88,11 @@ class _FundOrderCard extends StatelessWidget { final isDeposit = order.type == 1; final statusColor = _getStatusColor(order.status, isDeposit); + // 已出款的提現訂單顯示到賬金額,其餘顯示應付金額 + final displayAmount = (!isDeposit && order.status == 2 && order.receivableAmount != null) + ? order.receivableAmount + : order.amount; + return ShadCard( padding: AppSpacing.cardPadding, child: Column( @@ -97,7 +102,7 @@ class _FundOrderCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${isDeposit ? '+' : '-'}${order.amount} USDT', + '${isDeposit ? '+' : '-'}$displayAmount USDT', style: AppTextStyles.headlineMedium(context).copyWith( color: statusColor, fontWeight: FontWeight.w700, diff --git a/flutter_monisuo/lib/ui/pages/orders/fund_orders_page.dart b/flutter_monisuo/lib/ui/pages/orders/fund_orders_page.dart index 5a6a435..40f8563 100644 --- a/flutter_monisuo/lib/ui/pages/orders/fund_orders_page.dart +++ b/flutter_monisuo/lib/ui/pages/orders/fund_orders_page.dart @@ -340,7 +340,7 @@ class _FundOrdersPageState extends State { const SizedBox(height: AppSpacing.sm - AppSpacing.xs), ], if (order.fee != null && !order.isDeposit) ...[ - _buildDetailRow('手續費', '${order.fee}%'), + _buildDetailRow('手續費', '${order.fee} USDT'), const SizedBox(height: AppSpacing.sm - AppSpacing.xs), ], _buildDetailRow( @@ -400,7 +400,7 @@ class _FundOrdersPageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('應付金額', style: AppTextStyles.headlineSmall(context).copyWith(color: context.colors.onSurfaceVariant)), + Text(order.status == 2 ? '到賬金額' : '應付金額', style: AppTextStyles.headlineSmall(context).copyWith(color: context.colors.onSurfaceVariant)), Text('${order.receivableAmount} USDT', style: AppTextStyles.headlineMedium(context).copyWith(color: context.colors.onSurface)), ], ), diff --git a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart index 621011a..d2aab77 100644 --- a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart +++ b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart @@ -97,19 +97,27 @@ class _TradePageState extends State if (_tradeType == 0) { return _availableUsdt; } else { + // 賣出:qty * price 截斷到2位 final qty = double.tryParse(_availableCoinQty) ?? 0; - return (qty * price).toStringAsFixed(2); + return ((qty * price * 100).truncateToDouble() / 100).toStringAsFixed(2); } } - /// 計算數量 + /// 計算數量(向下截斷到4位小數,確保 price * quantity <= amount) String get _calculatedQuantity { final amount = double.tryParse(_amountController.text) ?? 0; final price = _selectedCoin?.price ?? 0; if (price <= 0 || amount <= 0) return '0'; - // 向下截斷到4位小數,避免回算超出金額 - final qty = amount / price; - return ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4); + // 使用與後端一致的截斷邏輯:先算原始數量,截斷到4位,再回算金額確保不超 + final rawQty = amount / price; + final truncatedQty = (rawQty * 10000).truncateToDouble() / 10000; + // 回算:roundedPrice * truncatedQty,確保不超過 amount + final roundedPrice = (price * 100).truncateToDouble() / 100; + if (roundedPrice * truncatedQty > amount) { + // 回退一個最小單位(0.0001) + return (truncatedQty - 0.0001).toStringAsFixed(4); + } + return truncatedQty.toStringAsFixed(4); } @override @@ -208,11 +216,16 @@ class _TradePageState extends State void _fillPercent(double pct) { final max = double.tryParse(_maxAmount) ?? 0; final value = max * pct; - // 向下截斷到2位小數,避免四捨五入超出可用餘額 - // 向下截斷到2位小數,再減0.01作為安全緩衝,避免精度問題導致餘額不足 - final truncated = ((value * 100).truncateToDouble() / 100); - final safe = truncated > 0.01 ? truncated - 0.01 : truncated; - _amountController.text = safe.toStringAsFixed(2); + + if (_tradeType == 0) { + // 買入:向下截斷到2位小數 + _amountController.text = + ((value * 100).truncateToDouble() / 100).toStringAsFixed(2); + } else { + // 賣出:_maxAmount 已是 qty*price 的四捨五入值,直接截斷 + _amountController.text = + ((value * 100).truncateToDouble() / 100).toStringAsFixed(2); + } setState(() {}); } diff --git a/monisuo-admin/src/pages/monisuo/users.vue b/monisuo-admin/src/pages/monisuo/users.vue index 471c6f4..8edc575 100644 --- a/monisuo-admin/src/pages/monisuo/users.vue +++ b/monisuo-admin/src/pages/monisuo/users.vue @@ -2,10 +2,10 @@ import { Icon } from '@iconify/vue' import { toast } from 'vue-sonner' -import type { User } from '@/services/api/monisuo-admin.api' +import type { User, UserStats } from '@/services/api/monisuo-admin.api' import { BasicPage } from '@/components/global-layout' -import { useGetUserListQuery, useUpdateUserStatusMutation } from '@/services/api/monisuo-admin.api' +import { useGetUserListQuery, useGetUserStatsQuery, useUpdateUserStatusMutation } from '@/services/api/monisuo-admin.api' const pageNum = ref(1) const pageSize = ref(10) @@ -29,12 +29,47 @@ const totalPages = computed(() => Math.ceil(total.value / pageSize.value)) // 用户详情弹窗 const showDetailDialog = ref(false) const selectedUser = ref(null) +const activeTab = ref('overview') + +const { data: statsData, isLoading: statsLoading } = useGetUserStatsQuery( + computed(() => selectedUser.value?.id ?? 0), +) +const stats = computed(() => statsData.value?.data as UserStats | undefined) function viewUserDetail(user: User) { selectedUser.value = user + activeTab.value = 'overview' showDetailDialog.value = true } +function formatAmount(val: number | string | undefined | null): string { + if (val == null) + return '0.00' + const n = typeof val === 'string' ? Number.parseFloat(val) : val + if (Number.isNaN(n)) + return '0.00' + return n.toFixed(2) +} + +function formatTime(val: string | undefined | null): string { + if (!val) + return '-' + return val.replace('T', ' ').substring(0, 19) +} + +function getFundOrderStatusText(type: number, status: number): string { + if (type === 1) { + const map: Record = { 1: '待付款', 2: '待确认', 3: '已完成', 4: '已驳回', 5: '已取消' } + return map[status] || '未知' + } + const map: Record = { 1: '待审批', 2: '已出款', 3: '已驳回', 4: '已取消', 5: '待财务审核' } + return map[status] || '未知' +} + +function getTradeDirectionText(dir: number): string { + return dir === 1 ? '买入' : '卖出' +} + async function toggleStatus(user: User) { const newStatus = user.status === 1 ? 0 : 1 const action = newStatus === 0 ? '禁用' : '启用' @@ -292,73 +327,469 @@ function handlePageSizeChange(size: unknown) { - + - + - 用户详情 - -
-
-
- 用户ID -
-
- {{ selectedUser.id }} -
- -
- 用户名 -
-
+ + 用户详情 + {{ selectedUser.username }} -
+ + + -
- 昵称 -
-
- {{ selectedUser.nickname || '-' }} -
- -
- 手机 -
-
- {{ selectedUser.phone || '-' }} -
- -
- 邮箱 -
-
- {{ selectedUser.email || '-' }} -
- -
- 状态 -
-
- - {{ selectedUser.status === 1 ? '正常' : '禁用' }} - -
- -
- 注册时间 -
-
- {{ selectedUser.createTime }} -
- -
- 更新时间 -
-
- {{ selectedUser.updateTime }} -
-
+ +
+
+ +
+ + + + + 概览 + + + 充提记录 + + + 推广信息 + + + 福利记录 + + + 交易记录 + + + + + + +
+
+ 用户ID +
+
+ {{ stats.user.id }} +
+
+ 用户名 +
+
+ {{ stats.user.username }} +
+
+ 昵称 +
+
+ {{ stats.user.nickname || '-' }} +
+
+ 手机 +
+
+ {{ stats.user.phone || '-' }} +
+
+ 推广码 +
+
+ {{ stats.user.referralCode || '-' }} +
+
+ 状态 +
+
+ + {{ stats.user.status === 1 ? '正常' : '禁用' }} + +
+
+ 注册时间 +
+
+ {{ formatTime(stats.user.createTime) }} +
+
+ + +
+ +
+ 资金账户余额 +
+
+ {{ formatAmount(stats.fundAccount?.balance) }} +
+
+ 冻结: {{ formatAmount(stats.fundAccount?.frozen) }} +
+
+ +
+ 累计充值 +
+
+ {{ formatAmount(stats.fundAccount?.totalDeposit) }} +
+
+ {{ stats.depositStats?.successCount || 0 }} 笔成功 +
+
+ +
+ 累计提现 +
+
+ {{ formatAmount(stats.fundAccount?.totalWithdraw) }} +
+
+ 手续费: {{ formatAmount(stats.withdrawStats?.totalFee) }} +
+
+ +
+ 累计领取福利 +
+
+ {{ formatAmount(stats.bonusStats?.totalBonusClaimed) }} +
+
+ {{ stats.bonusStats?.totalBonusCount || 0 }} 次领取 +
+
+
+ + +
+
+ 交易持仓 +
+
+ + + + 币种 + + 数量 + + + 现价 + + + 市值 + + + 成本价 + + + + + + + {{ t.coinCode }} + + + {{ Number(t.quantity).toFixed(4) }} + + + {{ formatAmount(t.price) }} + + + {{ formatAmount(t.value) }} + + + {{ formatAmount(t.avgPrice) }} + + + + +
+
+
+ 暂无持仓 +
+
+ + + + +
+ +
+ 充值笔数 +
+
+ {{ stats.depositStats?.totalCount || 0 }} + ({{ stats.depositStats?.successCount || 0 }} 笔成功) +
+
+ +
+ 充值总额 +
+
+ {{ formatAmount(stats.depositStats?.totalAmount) }} +
+
+ +
+ 提现笔数 +
+
+ {{ stats.withdrawStats?.totalCount || 0 }} + ({{ stats.withdrawStats?.successCount || 0 }} 笔成功) +
+
+ +
+ 提现总额 +
+
+ {{ formatAmount(stats.withdrawStats?.totalAmount) }} +
+
+
+ + +
+
+ + + + 时间 + 类型 + + 金额 + + + 手续费 + + + 到账 + + 状态 + + + + + + {{ formatTime(o.createTime) }} + + + + {{ o.type === 1 ? '充值' : '提现' }} + + + + {{ formatAmount(o.amount) }} + + + {{ o.fee ? formatAmount(o.fee) : '-' }} + + + {{ o.receivableAmount ? formatAmount(o.receivableAmount) : '-' }} + + + + {{ getFundOrderStatusText(o.type, o.status) }} + + + + + +
+
+
+ 暂无充提记录 +
+
+ + + + +
+ +
+ 推广码 +
+
+ {{ stats.user.referralCode || '-' }} +
+
+ +
+ 直接推广 +
+
+ {{ stats.referralStats?.directCount || 0 }} 人 +
+
+ +
+ 间接推广 +
+
+ {{ stats.referralStats?.indirectCount || 0 }} 人 +
+
+
+ + +
+
+ 直接推广人列表 +
+
+ + + + 用户名 + 昵称 + 注册时间 + 已充值 + + + + + + {{ r.username }} + + {{ r.nickname || '-' }} + + {{ formatTime(r.createTime) }} + + + + {{ r.deposited ? '是' : '否' }} + + + + + +
+
+
+ 暂无推广记录 +
+
+ + + + +
+ +
+ 累计领取金额 +
+
+ {{ formatAmount(stats.bonusStats?.totalBonusClaimed) }} USDT +
+
+ +
+ 累计领取次数 +
+
+ {{ stats.bonusStats?.totalBonusCount || 0 }} 次 +
+
+
+ + +
+
+ + + + 类型 + + 金额 + + 时间 + + + + + + + {{ b.type }} + + + + +{{ formatAmount(b.amount) }} + + + {{ formatTime(b.time) }} + + + + +
+
+
+ 暂无福利领取记录 +
+
+ + + +
+
+ + + + 时间 + 币种 + 方向 + + 价格 + + + 数量 + + + 金额 + + + + + + + {{ formatTime(t.createTime) }} + + + {{ t.coinCode }} + + + + {{ getTradeDirectionText(t.direction) }} + + + + {{ formatAmount(t.price) }} + + + {{ Number(t.quantity).toFixed(4) }} + + + {{ formatAmount(t.amount) }} + + + + +
+
+
+ 暂无交易记录 +
+
+
+
+ 关闭 diff --git a/monisuo-admin/src/services/api/monisuo-admin.api.ts b/monisuo-admin/src/services/api/monisuo-admin.api.ts index 091fb05..223c426 100644 --- a/monisuo-admin/src/services/api/monisuo-admin.api.ts +++ b/monisuo-admin/src/services/api/monisuo-admin.api.ts @@ -148,6 +148,83 @@ export function useGetUserDetailQuery(userId: number) { }) } +export interface UserStats { + user: User + fundAccount: { + balance: number + frozen: number + totalDeposit: number + totalWithdraw: number + } + tradeAccounts: Array<{ + coinCode: string + coinName: string + quantity: number + price: number + value: number + avgPrice: number + change24h: number + }> + depositStats: { + totalCount: number + totalAmount: number + successCount: number + successAmount: number + } + withdrawStats: { + totalCount: number + totalAmount: number + successCount: number + successAmount: number + totalFee: number + } + referralStats: { + directCount: number + indirectCount: number + referrals: Array<{ + userId: number + username: string + nickname: string + createTime: string + deposited: boolean + }> + } + bonusStats: { + totalBonusClaimed: number + totalBonusCount: number + records: Array<{ + type: string + amount: number + time: string + }> + } + recentFundOrders: OrderFund[] + recentTradeOrders: Array<{ + id: number + orderNo: string + coinCode: string + direction: number + price: number + quantity: number + amount: number + createTime: string + }> +} + +export function useGetUserStatsQuery(userId: Ref | ComputedRef | number) { + const { axiosInstance } = useAxios() + + return useQuery, AxiosError>({ + queryKey: computed(() => ['useGetUserStatsQuery', unref(userId)]), + queryFn: async () => { + const id = unref(userId) + const response = await axiosInstance.get('/admin/user/stats', { params: { userId: id } }) + return response.data + }, + enabled: computed(() => !!unref(userId)), + }) +} + export function useUpdateUserStatusMutation() { const { axiosInstance } = useAxios() const queryClient = useQueryClient() diff --git a/src/main/java/com/it/rattan/monisuo/controller/AdminController.java b/src/main/java/com/it/rattan/monisuo/controller/AdminController.java index 720dce3..385b7b2 100644 --- a/src/main/java/com/it/rattan/monisuo/controller/AdminController.java +++ b/src/main/java/com/it/rattan/monisuo/controller/AdminController.java @@ -254,6 +254,25 @@ public class AdminController { return Result.success(data); } + /** + * 用户统计数据(管理后台详情面板) + */ + @GetMapping("/user/stats") + public Result> getUserStats(@RequestParam Long userId) { + if (!UserContext.isSuperAdmin()) { + return Result.fail("无权限访问"); + } + + User user = userService.getById(userId); + if (user == null) { + return Result.fail("用户不存在"); + } + + Map stats = assetService.getUserStats(userId); + stats.put("user", user); + return Result.success(stats); + } + /** * 禁用/启用用户 */ diff --git a/src/main/java/com/it/rattan/monisuo/service/AssetService.java b/src/main/java/com/it/rattan/monisuo/service/AssetService.java index d2a64c1..da91bea 100644 --- a/src/main/java/com/it/rattan/monisuo/service/AssetService.java +++ b/src/main/java/com/it/rattan/monisuo/service/AssetService.java @@ -5,11 +5,15 @@ import com.it.rattan.monisuo.entity.AccountFlow; import com.it.rattan.monisuo.entity.AccountFund; import com.it.rattan.monisuo.entity.AccountTrade; import com.it.rattan.monisuo.entity.Coin; +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.AccountFlowMapper; import com.it.rattan.monisuo.mapper.AccountFundMapper; import com.it.rattan.monisuo.mapper.AccountTradeMapper; +import com.it.rattan.monisuo.mapper.OrderFundMapper; import com.it.rattan.monisuo.mapper.OrderTradeMapper; +import com.it.rattan.monisuo.mapper.UserMapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.it.rattan.monisuo.util.OrderNoUtil; import org.springframework.beans.factory.annotation.Autowired; @@ -44,6 +48,12 @@ public class AssetService { @Autowired private AccountFlowMapper accountFlowMapper; + @Autowired + private OrderFundMapper orderFundMapper; + + @Autowired + private UserMapper userMapper; + @Autowired private CoinService coinService; @@ -379,4 +389,142 @@ public class AssetService { result.put("totalProfit", totalProfit); return result; } + + /** + * 获取用户统计数据(管理后台用) + */ + public Map getUserStats(Long userId) { + Map result = new HashMap<>(); + + // 1. 资金账户 + AccountFund fund = getOrCreateFundAccount(userId); + Map fundAccount = new HashMap<>(); + fundAccount.put("balance", fund.getBalance()); + fundAccount.put("frozen", fund.getFrozen()); + fundAccount.put("totalDeposit", fund.getTotalDeposit()); + fundAccount.put("totalWithdraw", fund.getTotalWithdraw()); + result.put("fundAccount", fundAccount); + + // 2. 交易账户持仓 + result.put("tradeAccounts", getTradeAccount(userId)); + + // 3. 充提统计(一次查询按类型分组,避免两次查询) + LambdaQueryWrapper fundOrderWrapper = new LambdaQueryWrapper<>(); + fundOrderWrapper.eq(OrderFund::getUserId, userId); + List allFundOrders = orderFundMapper.selectList(fundOrderWrapper); + + List allDeposits = allFundOrders.stream() + .filter(o -> o.getType() == 1).collect(Collectors.toList()); + List allWithdraws = allFundOrders.stream() + .filter(o -> o.getType() == 2).collect(Collectors.toList()); + + Map depositStats = new HashMap<>(); + depositStats.put("totalCount", allDeposits.size()); + depositStats.put("totalAmount", allDeposits.stream().map(OrderFund::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + depositStats.put("successCount", allDeposits.stream().filter(o -> o.getStatus() == 3).count()); + depositStats.put("successAmount", allDeposits.stream().filter(o -> o.getStatus() == 3).map(OrderFund::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + result.put("depositStats", depositStats); + + Map withdrawStats = new HashMap<>(); + withdrawStats.put("totalCount", allWithdraws.size()); + withdrawStats.put("totalAmount", allWithdraws.stream().map(OrderFund::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + withdrawStats.put("successCount", allWithdraws.stream().filter(o -> o.getStatus() == 2).count()); + withdrawStats.put("successAmount", allWithdraws.stream().filter(o -> o.getStatus() == 2).map(OrderFund::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + withdrawStats.put("totalFee", allWithdraws.stream().filter(o -> o.getStatus() == 2).map(o -> o.getFee() != null ? o.getFee() : BigDecimal.ZERO).reduce(BigDecimal.ZERO, BigDecimal::add)); + result.put("withdrawStats", withdrawStats); + + // 5. 推广统计(优化:批量查询消除 N+1) + // 直接推广人(referredBy = userId) + LambdaQueryWrapper referralWrapper = new LambdaQueryWrapper<>(); + referralWrapper.eq(User::getReferredBy, userId) + .orderByDesc(User::getCreateTime); + List directReferrals = userMapper.selectList(referralWrapper); + List directIds = directReferrals.stream().map(User::getId).collect(Collectors.toList()); + + // 间接推广人:一次 IN 查询 + long indirectCount = 0; + if (!directIds.isEmpty()) { + LambdaQueryWrapper indirectWrapper = new LambdaQueryWrapper<>(); + indirectWrapper.in(User::getReferredBy, directIds); + indirectCount = userMapper.selectCount(indirectWrapper); + } + + // 批量查询直接推广人是否已充值(一次 IN 查询) + Set depositedUserIds = new HashSet<>(); + if (!directIds.isEmpty()) { + LambdaQueryWrapper batchDepositWrapper = new LambdaQueryWrapper<>(); + batchDepositWrapper.in(OrderFund::getUserId, directIds) + .eq(OrderFund::getType, 1).eq(OrderFund::getStatus, 3) + .select(OrderFund::getUserId) + .groupBy(OrderFund::getUserId); + orderFundMapper.selectList(batchDepositWrapper) + .forEach(o -> depositedUserIds.add(o.getUserId())); + } + + List> referralList = new ArrayList<>(); + for (User ref : directReferrals) { + Map refInfo = new HashMap<>(); + refInfo.put("userId", ref.getId()); + refInfo.put("username", ref.getUsername()); + refInfo.put("nickname", ref.getNickname()); + refInfo.put("createTime", ref.getCreateTime()); + refInfo.put("deposited", depositedUserIds.contains(ref.getId())); + referralList.add(refInfo); + } + Map referralStats = new HashMap<>(); + referralStats.put("directCount", directReferrals.size()); + referralStats.put("indirectCount", indirectCount); + referralStats.put("referrals", referralList); + result.put("referralStats", referralStats); + + // 6. 福利统计(从 account_flow 查询福利类型记录) + LambdaQueryWrapper bonusWrapper = new LambdaQueryWrapper<>(); + bonusWrapper.eq(AccountFlow::getUserId, userId) + .eq(AccountFlow::getFlowType, 7) + .orderByDesc(AccountFlow::getCreateTime); + List bonusFlows = accountFlowMapper.selectList(bonusWrapper); + BigDecimal totalBonusClaimed = bonusFlows.stream() + .map(AccountFlow::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + List> bonusRecords = new ArrayList<>(); + for (AccountFlow flow : bonusFlows) { + Map record = new HashMap<>(); + String remark = flow.getRemark() != null ? flow.getRemark() : "福利"; + String type; + if (remark.startsWith("新人首充")) { + type = "新人首充福利"; + } else if (remark.startsWith("间接推广")) { + type = "间接推广奖励"; + } else if (remark.startsWith("邀请奖励")) { + type = "直接推广奖励"; + } else { + type = remark; + } + record.put("type", type); + record.put("amount", flow.getAmount()); + record.put("time", flow.getCreateTime()); + bonusRecords.add(record); + } + Map bonusStats = new HashMap<>(); + bonusStats.put("totalBonusClaimed", totalBonusClaimed); + bonusStats.put("totalBonusCount", bonusFlows.size()); + bonusStats.put("records", bonusRecords); + result.put("bonusStats", bonusStats); + + // 7. 最近充提订单(最近20笔) + LambdaQueryWrapper recentOrderWrapper = new LambdaQueryWrapper<>(); + recentOrderWrapper.eq(OrderFund::getUserId, userId) + .orderByDesc(OrderFund::getCreateTime) + .last("LIMIT 20"); + result.put("recentFundOrders", orderFundMapper.selectList(recentOrderWrapper)); + + // 8. 最近交易订单(最近20笔) + LambdaQueryWrapper recentTradeWrapper = new LambdaQueryWrapper<>(); + recentTradeWrapper.eq(OrderTrade::getUserId, userId) + .orderByDesc(OrderTrade::getCreateTime) + .last("LIMIT 20"); + result.put("recentTradeOrders", orderTradeMapper.selectList(recentTradeWrapper)); + + return result; + } }