Przeglądaj źródła

feat(analytics): Web端统计分析——后端AnalyticsService+前端指标卡趋势图漏斗分销

- AnalyticsService: 收入趋势/用户增长/概要统计/转化漏斗/分销Top10/提现统计
- UserRepository: 新增findByCreatedAtBetweenOrderByCreatedAtAsc
- AdminController: 6个analytics端点(summary/revenue-trend/user-growth/funnel/distribution/withdraw-stats)
- 前端统计分析页面: 日期范围+指标卡+收入趋势图(日/周/月切换)+用户增长图
- 转化漏斗图+分销Top10表格+提现统计(通道饼图)+CSV导出
liaoxg 3 miesięcy temu
rodzic
commit
c47d5da1d8

+ 4 - 0
admin/src/layout/index.vue

@@ -41,6 +41,10 @@
           <el-icon><PieChart /></el-icon>
           <span>分析记录</span>
         </el-menu-item>
+        <el-menu-item index="/analytics">
+          <el-icon><TrendCharts /></el-icon>
+          <span>统计分析</span>
+        </el-menu-item>
       </el-menu>
     </el-aside>
     <el-container>

+ 2 - 1
admin/src/router/index.ts

@@ -16,7 +16,8 @@ const router = createRouter({
         { path: 'commissions', name: 'Commissions', component: () => import('@/views/commissions/index.vue') },
         { path: 'withdraw', name: 'Withdraw', component: () => import('@/views/withdraw/index.vue') },
         { path: 'settings', name: 'Settings', component: () => import('@/views/settings/index.vue') },
-        { path: 'charts', name: 'Charts', component: () => import('@/views/charts/index.vue') }
+        { path: 'charts', name: 'Charts', component: () => import('@/views/charts/index.vue') },
+        { path: 'analytics', name: 'Analytics', component: () => import('@/views/analytics/index.vue') }
       ]
     }
   ]

+ 570 - 0
admin/src/views/analytics/index.vue

@@ -0,0 +1,570 @@
+<template>
+  <div class="analytics-page">
+
+    <!-- Date range and controls -->
+    <el-card shadow="hover" class="controls-card">
+      <div class="controls-row">
+        <div class="date-pickers">
+          <span class="control-label">统计范围</span>
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            @change="onDateRangeChange"
+            style="width: 260px"
+          />
+          <el-button type="primary" @click="loadAll" :loading="loading" style="margin-left: 8px">查询</el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- Summary cards -->
+    <el-row :gutter="16" class="stats-row">
+      <el-col :span="4" v-for="card in summaryCards" :key="card.label">
+        <el-card shadow="hover" class="stat-card" :body-style="{ padding: '16px' }">
+          <div class="stat-label">{{ card.label }}</div>
+          <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- Tabs: 趋势图 + 高级分析 -->
+    <el-card shadow="hover">
+      <el-tabs v-model="activeTab">
+        <el-tab-pane label="收入趋势" name="revenue">
+          <div class="chart-toolbar">
+            <el-radio-group v-model="revenueGroupBy" size="small" @change="loadRevenueTrend">
+              <el-radio-button value="day">按日</el-radio-button>
+              <el-radio-button value="week">按周</el-radio-button>
+              <el-radio-button value="month">按月</el-radio-button>
+            </el-radio-group>
+          </div>
+          <div ref="revenueChartRef" class="chart-container"></div>
+        </el-tab-pane>
+
+        <el-tab-pane label="用户增长" name="users">
+          <div class="chart-toolbar">
+            <el-radio-group v-model="userGroupBy" size="small" @change="loadUserGrowth">
+              <el-radio-button value="day">按日</el-radio-button>
+              <el-radio-button value="week">按周</el-radio-button>
+              <el-radio-button value="month">按月</el-radio-button>
+            </el-radio-group>
+          </div>
+          <div ref="userChartRef" class="chart-container"></div>
+        </el-tab-pane>
+
+        <!-- Phase 2 tabs -->
+        <el-tab-pane label="转化漏斗" name="funnel">
+          <div ref="funnelChartRef" class="chart-container"></div>
+        </el-tab-pane>
+
+        <el-tab-pane label="分销 Top10" name="distribution">
+          <el-table :data="topPromoters" v-loading="distLoading" stripe size="small">
+            <el-table-column label="排名" type="index" width="60" />
+            <el-table-column label="昵称" width="160">
+              <template #default="{ row }">
+                <div class="promoter-cell">
+                  <el-avatar :src="row.avatarUrl" :size="28" style="margin-right: 8px" />
+                  <span>{{ row.nickname }}</span>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column prop="directCount" label="直推" width="80" align="center" />
+            <el-table-column prop="indirectCount" label="间推" width="80" align="center" />
+            <el-table-column label="总佣金" width="120" align="right">
+              <template #default="{ row }">¥{{ (row.totalCommission / 100).toFixed(2) }}</template>
+            </el-table-column>
+          </el-table>
+          <div class="dist-summary">
+            <span>推广者总数:<strong>{{ distSummary.totalPromoters }}</strong></span>
+            <span style="margin-left: 24px">总佣金:<strong>¥{{ (distSummary.totalCommission / 100).toFixed(2) }}</strong></span>
+            <span style="margin-left: 24px">平均佣金:<strong>¥{{ (distSummary.avgCommission / 100).toFixed(2) }}</strong></span>
+          </div>
+          <div class="export-bar">
+            <el-button size="small" @click="exportDistribution">导出 CSV</el-button>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="提现统计" name="withdraw">
+          <div v-loading="wdLoading" class="withdraw-stats-grid">
+            <el-row :gutter="16">
+              <el-col :span="6" v-for="s in withdrawStatCards" :key="s.label">
+                <el-card shadow="hover" class="stat-card" :body-style="{ padding: '14px' }">
+                  <div class="stat-label">{{ s.label }}</div>
+                  <div class="stat-value" :style="{ color: s.color, fontSize: '20px' }">{{ s.value }}</div>
+                </el-card>
+              </el-col>
+            </el-row>
+            <div ref="withdrawChartRef" class="chart-container" style="height: 240px; margin-top: 16px"></div>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
+import * as echarts from 'echarts'
+import request from '@/utils/request'
+
+/* ──────────── State ──────────── */
+
+const loading = ref(false)
+const distLoading = ref(false)
+const wdLoading = ref(false)
+const activeTab = ref('revenue')
+const revenueGroupBy = ref('day')
+const userGroupBy = ref('day')
+
+const dateRange = ref<[string, string]>([getDefaultStart(), getDefaultEnd()])
+
+const summary = ref<Record<string, any>>({})
+const revenueTrend = ref<any[]>([])
+const userGrowth = ref<any[]>([])
+const topPromoters = ref<any[]>([])
+const distSummary = ref<any>({})
+const withdrawStats = ref<Record<string, any>>({})
+
+const revenueChartRef = ref<HTMLElement>()
+const userChartRef = ref<HTMLElement>()
+const funnelChartRef = ref<HTMLElement>()
+const withdrawChartRef = ref<HTMLElement>()
+
+/* ──────────── Helpers ──────────── */
+
+function getDefaultStart(): string {
+  const d = new Date()
+  d.setDate(d.getDate() - 30)
+  return d.toISOString().slice(0, 10)
+}
+
+function getDefaultEnd(): string {
+  return new Date().toISOString().slice(0, 10)
+}
+
+function formatYuan(fen: number): string {
+  return '¥' + (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
+function formatPercent(val: number): string {
+  return val.toFixed(1) + '%'
+}
+
+function fmt(dateStr?: string): string | undefined {
+  return dateStr || undefined
+}
+
+/* ──────────── Summary Cards ──────────── */
+
+const summaryCards = computed(() => [
+  { label: '总收入', value: formatYuan(summary.value.totalRevenue ?? 0), color: '#10B981' },
+  { label: '订单数', value: summary.value.totalOrders ?? '-', color: '#3B82F6' },
+  { label: '平均客单价', value: formatYuan(summary.value.avgOrderValue ?? 0), color: '#8B5CF6' },
+  { label: '总用户', value: summary.value.totalUsers ?? '-', color: '#1F2937' },
+  { label: '新增用户', value: summary.value.newUsers ?? '-', color: '#3B82F6' },
+  { label: 'VIP 用户', value: summary.value.vipUsers ?? '-', color: '#F59E0B' },
+  { label: '新增 VIP', value: summary.value.newVip ?? '-', color: '#F59E0B' },
+  { label: 'VIP 转化率', value: summary.value.vipConversionRate != null ? formatPercent(summary.value.vipConversionRate) : '-', color: '#EF4444' },
+])
+
+/* ──────────── Withdraw Stat Cards ──────────── */
+
+const withdrawStatCards = computed(() => {
+  const ws = withdrawStats.value
+  return [
+    { label: '提现总数', value: ws.totalCount ?? '-', color: '#1F2937' },
+    { label: '提现总额', value: formatYuan(ws.totalAmount ?? 0), color: '#10B981' },
+    { label: '成功金额', value: formatYuan(ws.successAmount ?? 0), color: '#3B82F6' },
+    { label: '成功率', value: ws.successRate != null ? formatPercent(ws.successRate) : '-', color: '#8B5CF6' },
+  ]
+})
+
+/* ──────────── Date change ──────────── */
+
+function onDateRangeChange() {
+  loadAll()
+}
+
+/* ──────────── Data Loading ──────────── */
+
+async function loadAll() {
+  loading.value = true
+  try {
+    const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
+    const payload = { startDate: start, endDate: end }
+
+    const [summaryData, revenueData, userData] = await Promise.all([
+      request.post('/admin/analytics/summary', payload) as Promise<any>,
+      request.post('/admin/analytics/revenue-trend', { ...payload, groupBy: revenueGroupBy.value }) as Promise<any>,
+      request.post('/admin/analytics/user-growth', { ...payload, groupBy: userGroupBy.value }) as Promise<any>,
+    ])
+
+    summary.value = summaryData
+    revenueTrend.value = revenueData
+    userGrowth.value = userData
+    nextTick(() => {
+      renderRevenueChart()
+      renderUserChart()
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadRevenueTrend() {
+  const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
+  revenueTrend.value = await request.post('/admin/analytics/revenue-trend', {
+    startDate: start, endDate: end, groupBy: revenueGroupBy.value
+  }) as any
+  nextTick(renderRevenueChart)
+}
+
+async function loadUserGrowth() {
+  const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
+  userGrowth.value = await request.post('/admin/analytics/user-growth', {
+    startDate: start, endDate: end, groupBy: userGroupBy.value
+  }) as any
+  nextTick(renderUserChart)
+}
+
+/* ──────────── Revenue Chart ──────────── */
+
+function renderRevenueChart() {
+  if (!revenueChartRef.value || revenueTrend.value.length === 0) {
+    if (revenueChartRef.value) {
+      const chart = echarts.getInstanceByDom(revenueChartRef.value) || echarts.init(revenueChartRef.value)
+      chart.setOption({
+        graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
+      })
+    }
+    return
+  }
+
+  const chart = echarts.getInstanceByDom(revenueChartRef.value) || echarts.init(revenueChartRef.value)
+  const dates = revenueTrend.value.map((d: any) => d.date)
+  const revenue = revenueTrend.value.map((d: any) => (d.revenue / 100).toFixed(2))
+  const orderCount = revenueTrend.value.map((d: any) => d.orderCount)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'cross' },
+      formatter: (params: any) => {
+        let html = `<strong>${params[0].axisValue}</strong><br/>`
+        params.forEach((p: any) => {
+          html += `${p.marker} ${p.seriesName}:${p.seriesName === '收入(元)' ? '¥' : ''}${p.value}<br/>`
+        })
+        return html
+      }
+    },
+    legend: { data: ['收入(元)', '订单数'], bottom: 0 },
+    grid: { left: '8%', right: '5%', top: '6%', bottom: '22%' },
+    xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
+    yAxis: [
+      { type: 'value', name: '收入(元)' },
+      { type: 'value', name: '订单数' },
+    ],
+    series: [
+      {
+        name: '收入(元)',
+        type: 'line',
+        data: revenue,
+        smooth: true,
+        lineStyle: { color: '#10B981', width: 2 },
+        itemStyle: { color: '#10B981' },
+        areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: 'rgba(16,185,129,0.25)' },
+          { offset: 1, color: 'rgba(16,185,129,0.02)' },
+        ])},
+      },
+      {
+        name: '订单数',
+        type: 'bar',
+        yAxisIndex: 1,
+        data: orderCount,
+        barWidth: '40%',
+        itemStyle: { color: '#3B82F6', borderRadius: [3, 3, 0, 0] },
+      },
+    ],
+  })
+}
+
+/* ──────────── User Growth Chart ──────────── */
+
+function renderUserChart() {
+  if (!userChartRef.value || userGrowth.value.length === 0) {
+    if (userChartRef.value) {
+      const chart = echarts.getInstanceByDom(userChartRef.value) || echarts.init(userChartRef.value)
+      chart.setOption({
+        graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
+      })
+    }
+    return
+  }
+
+  const chart = echarts.getInstanceByDom(userChartRef.value) || echarts.init(userChartRef.value)
+  const dates = userGrowth.value.map((d: any) => d.date)
+  const newUsers = userGrowth.value.map((d: any) => d.newUsers)
+  const newVip = userGrowth.value.map((d: any) => d.newVip)
+  const newPractitioner = userGrowth.value.map((d: any) => d.newPractitioner)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'cross' },
+      formatter: (params: any) => {
+        let html = `<strong>${params[0].axisValue}</strong><br/>`
+        params.forEach((p: any) => {
+          html += `${p.marker} ${p.seriesName}:${p.value}<br/>`
+        })
+        return html
+      }
+    },
+    legend: { data: ['新增用户', '新增VIP', '新增能量师'], bottom: 0 },
+    grid: { left: '8%', right: '5%', top: '6%', bottom: '22%' },
+    xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
+    yAxis: { type: 'value', minInterval: 1 },
+    series: [
+      {
+        name: '新增用户',
+        type: 'bar',
+        stack: 'users',
+        data: newUsers,
+        itemStyle: { color: '#3B82F6', borderRadius: [0, 0, 0, 0] },
+      },
+      {
+        name: '新增VIP',
+        type: 'bar',
+        stack: 'users',
+        data: newVip,
+        itemStyle: { color: '#F59E0B' },
+      },
+      {
+        name: '新增能量师',
+        type: 'bar',
+        stack: 'users',
+        data: newPractitioner,
+        itemStyle: { color: '#10B981', borderRadius: [3, 3, 0, 0] },
+      },
+    ],
+  })
+}
+
+/* ──────────── Funnel Chart (Phase 2) ──────────── */
+
+async function loadFunnel() {
+  const data: any = await request.post('/admin/analytics/funnel', {})
+  if (!funnelChartRef.value) return
+
+  const chart = echarts.getInstanceByDom(funnelChartRef.value) || echarts.init(funnelChartRef.value)
+  chart.setOption({
+    tooltip: { trigger: 'item', formatter: '{b}: {c}' },
+    series: [{
+      type: 'funnel',
+      left: '15%',
+      right: '15%',
+      minSize: '20%',
+      maxSize: '100%',
+      sort: 'descending',
+      gap: 4,
+      label: { show: true, formatter: '{b}\n{c} ({d}%)', fontSize: 13 },
+      itemStyle: { borderColor: '#fff', borderWidth: 2 },
+      data: [
+        { name: '注册用户', value: data.registered, itemStyle: { color: '#3B82F6' } },
+        { name: 'C端付费', value: data.paidC, itemStyle: { color: '#10B981' } },
+        { name: '超级会员', value: data.paidSuper, itemStyle: { color: '#8B5CF6' } },
+        { name: '能量师', value: data.practitioner, itemStyle: { color: '#F59E0B' } },
+      ],
+    }],
+  })
+}
+
+/* ──────────── Distribution (Phase 2) ──────────── */
+
+async function loadDistribution() {
+  distLoading.value = true
+  try {
+    const data: any = await request.post('/admin/analytics/distribution', {})
+    topPromoters.value = data.topPromoters ?? []
+    distSummary.value = {
+      totalPromoters: data.totalPromoters ?? 0,
+      totalCommission: data.totalCommission ?? 0,
+      avgCommission: data.avgCommission ?? 0,
+    }
+  } finally {
+    distLoading.value = false
+  }
+}
+
+/* ──────────── Withdraw Stats (Phase 2) ──────────── */
+
+async function loadWithdrawStats() {
+  wdLoading.value = true
+  try {
+    const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
+    const data: any = await request.post('/admin/analytics/withdraw-stats', { startDate: start, endDate: end })
+    withdrawStats.value = data
+
+    nextTick(() => {
+      if (!withdrawChartRef.value) return
+      const chart = echarts.getInstanceByDom(withdrawChartRef.value) || echarts.init(withdrawChartRef.value)
+      const byChannel = data.byChannel ?? {}
+      const channelNames: Record<string, string> = {
+        REFUND: '退费',
+        WECHAT_TRANSFER: '微信企业付款',
+        THIRD_PARTY: '第三方',
+      }
+      const pieData = Object.entries(byChannel).map(([k, v]) => ({
+        name: channelNames[k] || k,
+        value: (v as number) / 100,
+      }))
+      if (pieData.length === 0) {
+        chart.setOption({
+          graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
+        })
+      } else {
+        chart.setOption({
+          tooltip: { trigger: 'item', formatter: '{b}: ¥{c}' },
+          series: [{
+            type: 'pie',
+            radius: ['38%', '62%'],
+            data: pieData,
+            label: { show: true, formatter: '{b}\n¥{c}' },
+          }],
+        })
+      }
+    })
+  } finally {
+    wdLoading.value = false
+  }
+}
+
+/* ──────────── CSV Export (Phase 2) ──────────── */
+
+function exportDistribution() {
+  if (topPromoters.value.length === 0) return
+  const headers = ['排名,昵称,直推数,间推数,总佣金(元)']
+  const rows = topPromoters.value.map((p: any, i: number) => {
+    const nick = p.nickname?.replace(/,/g, ' ') || '未知'
+    return `${i + 1},${nick},${p.directCount},${p.indirectCount},${(p.totalCommission / 100).toFixed(2)}`
+  })
+  const csv = '\uFEFF' + [...headers, ...rows].join('\n')
+  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = 'distribution-top10.csv'
+  a.click()
+  URL.revokeObjectURL(url)
+}
+
+/* ──────────── Tab Change Handler ──────────── */
+
+watch(activeTab, (tab) => {
+  if (tab === 'funnel') {
+    nextTick(loadFunnel)
+  } else if (tab === 'distribution') {
+    loadDistribution()
+  } else if (tab === 'withdraw') {
+    loadWithdrawStats()
+  } else if (tab === 'revenue') {
+    nextTick(renderRevenueChart)
+  } else if (tab === 'users') {
+    nextTick(renderUserChart)
+  }
+})
+
+/* ──────────── Lifecycle ──────────── */
+
+onMounted(() => {
+  loadAll()
+})
+</script>
+
+<style scoped lang="scss">
+.analytics-page {
+  padding: 0;
+}
+
+.controls-card {
+  margin-bottom: 16px;
+}
+
+.controls-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.date-pickers {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.control-label {
+  font-size: 14px;
+  color: #374151;
+  white-space: nowrap;
+}
+
+.stats-row {
+  margin-bottom: 16px;
+}
+
+.stat-card {
+  text-align: center;
+}
+
+.stat-label {
+  font-size: 12px;
+  color: #6B7280;
+  margin-bottom: 6px;
+}
+
+.stat-value {
+  font-size: 22px;
+  font-weight: 700;
+  color: #1F2937;
+}
+
+.chart-toolbar {
+  margin-bottom: 12px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.chart-container {
+  height: 340px;
+  width: 100%;
+}
+
+.promoter-cell {
+  display: flex;
+  align-items: center;
+}
+
+.dist-summary {
+  margin-top: 16px;
+  padding: 12px 16px;
+  background: #F9FAFB;
+  border-radius: 6px;
+  font-size: 14px;
+  color: #374151;
+}
+
+.export-bar {
+  margin-top: 12px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.withdraw-stats-grid {
+  min-height: 100px;
+}
+</style>

+ 91 - 1
num-server/src/main/java/com/etotem/num/controller/AdminController.java

@@ -11,6 +11,7 @@ import com.etotem.num.repository.UserRepository;
 import com.etotem.num.repository.OrderRepository;
 import com.etotem.num.repository.ChartRecordRepository;
 import com.etotem.num.repository.CommissionRepository;
+import com.etotem.num.service.AnalyticsService;
 import com.etotem.num.service.ConfigService;
 import com.etotem.num.service.WithdrawService;
 import org.springframework.beans.factory.annotation.Value;
@@ -19,7 +20,9 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -40,13 +43,15 @@ public class AdminController {
     private final ChartRecordRepository chartRecordRepository;
     private final ConfigService configService;
     private final JwtConfig jwtConfig;
+    private final AnalyticsService analyticsService;
 
     public AdminController(UserRepository userRepository, WithdrawService withdrawService,
                            CommissionRepository commissionRepository,
                            OrderRepository orderRepository,
                            ChartRecordRepository chartRecordRepository,
                            ConfigService configService,
-                           JwtConfig jwtConfig) {
+                           JwtConfig jwtConfig,
+                           AnalyticsService analyticsService) {
         this.userRepository = userRepository;
         this.withdrawService = withdrawService;
         this.commissionRepository = commissionRepository;
@@ -54,6 +59,7 @@ public class AdminController {
         this.chartRecordRepository = chartRecordRepository;
         this.configService = configService;
         this.jwtConfig = jwtConfig;
+        this.analyticsService = analyticsService;
     }
 
     @PostMapping("/login")
@@ -213,4 +219,88 @@ public class AdminController {
         }
         return Result.success(null);
     }
+
+    // ==================== Analytics Endpoints ====================
+
+    /**
+     * Phase 1: 核心指标汇总(支持日期范围)。
+     * POST /api/admin/analytics/summary
+     * Body: { startDate?: "2026-01-01", endDate?: "2026-12-31" }
+     */
+    @PostMapping("/analytics/summary")
+    public Result<Map<String, Object>> analyticsSummary(@RequestBody Map<String, String> body) {
+        LocalDateTime start = parseDateParam(body.get("startDate"), LocalDate.now().minusDays(30));
+        LocalDateTime end = parseDateParam(body.get("endDate"), LocalDate.now());
+        return Result.success(analyticsService.getSummaryStats(start, end));
+    }
+
+    /**
+     * Phase 1: 收入趋势统计。
+     * POST /api/admin/analytics/revenue-trend
+     * Body: { startDate?, endDate?, groupBy?: "day"|"week"|"month" }
+     */
+    @PostMapping("/analytics/revenue-trend")
+    public Result<List<Map<String, Object>>> revenueTrend(@RequestBody Map<String, String> body) {
+        LocalDateTime start = parseDateParam(body.get("startDate"), LocalDate.now().minusDays(30));
+        LocalDateTime end = parseDateParam(body.get("endDate"), LocalDate.now());
+        String groupBy = body.getOrDefault("groupBy", "day");
+        return Result.success(analyticsService.getRevenueTrend(start, end, groupBy));
+    }
+
+    /**
+     * Phase 1: 用户增长趋势统计。
+     * POST /api/admin/analytics/user-growth
+     * Body: { startDate?, endDate?, groupBy?: "day"|"week"|"month" }
+     */
+    @PostMapping("/analytics/user-growth")
+    public Result<List<Map<String, Object>>> userGrowth(@RequestBody Map<String, String> body) {
+        LocalDateTime start = parseDateParam(body.get("startDate"), LocalDate.now().minusDays(30));
+        LocalDateTime end = parseDateParam(body.get("endDate"), LocalDate.now());
+        String groupBy = body.getOrDefault("groupBy", "day");
+        return Result.success(analyticsService.getUserGrowthTrend(start, end, groupBy));
+    }
+
+    /**
+     * Phase 2: 转化漏斗统计。
+     * POST /api/admin/analytics/funnel
+     */
+    @PostMapping("/analytics/funnel")
+    public Result<Map<String, Object>> conversionFunnel() {
+        return Result.success(analyticsService.getConversionFunnel());
+    }
+
+    /**
+     * Phase 2: 分销统计。
+     * POST /api/admin/analytics/distribution
+     */
+    @PostMapping("/analytics/distribution")
+    public Result<Map<String, Object>> distributionStats() {
+        return Result.success(analyticsService.getDistributionStats());
+    }
+
+    /**
+     * Phase 2: 提现统计。
+     * POST /api/admin/analytics/withdraw-stats
+     * Body: { startDate?, endDate? }
+     */
+    @PostMapping("/analytics/withdraw-stats")
+    public Result<Map<String, Object>> withdrawStats(@RequestBody Map<String, String> body) {
+        LocalDateTime start = parseDateParam(body.get("startDate"), LocalDate.now().minusDays(30));
+        LocalDateTime end = parseDateParam(body.get("endDate"), LocalDate.now());
+        return Result.success(analyticsService.getWithdrawStats(start, end));
+    }
+
+    /**
+     * Parse date string parameter, fallback to default.
+     */
+    private LocalDateTime parseDateParam(String dateStr, LocalDate fallback) {
+        if (dateStr == null || dateStr.isEmpty()) {
+            return fallback.atStartOfDay();
+        }
+        try {
+            return LocalDate.parse(dateStr).atStartOfDay();
+        } catch (Exception e) {
+            return fallback.atStartOfDay();
+        }
+    }
 }

+ 3 - 0
num-server/src/main/java/com/etotem/num/repository/UserRepository.java

@@ -24,6 +24,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
     // Bonus settlement: find users by vipType
     List<User> findByVipType(String vipType);
 
+    // Analytics: user registration trends
+    List<User> findByCreatedAtBetweenOrderByCreatedAtAsc(LocalDateTime start, LocalDateTime end);
+
     @Modifying
     @Transactional
     @Query("UPDATE User u SET u.directCount = COALESCE(u.directCount, 0) + 1 WHERE u.id = :userId")

+ 322 - 0
num-server/src/main/java/com/etotem/num/service/AnalyticsService.java

@@ -0,0 +1,322 @@
+package com.etotem.num.service;
+
+import com.etotem.num.entity.Order;
+import com.etotem.num.entity.User;
+import com.etotem.num.entity.Commission;
+import com.etotem.num.entity.Withdraw;
+import com.etotem.num.repository.*;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Web 端统计分析服务。
+ * 提供时间序列统计、趋势分析、转化漏斗、分销网络统计等功能。
+ */
+@Service
+public class AnalyticsService {
+
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    private final OrderRepository orderRepository;
+    private final UserRepository userRepository;
+    private final CommissionRepository commissionRepository;
+    private final WithdrawRepository withdrawRepository;
+
+    public AnalyticsService(OrderRepository orderRepository,
+                            UserRepository userRepository,
+                            CommissionRepository commissionRepository,
+                            WithdrawRepository withdrawRepository) {
+        this.orderRepository = orderRepository;
+        this.userRepository = userRepository;
+        this.commissionRepository = commissionRepository;
+        this.withdrawRepository = withdrawRepository;
+    }
+
+    // ==================== Phase 1: 核心统计 ====================
+
+    /**
+     * 收入时间序列统计。
+     * @param startDate 开始时间
+     * @param endDate 结束时间
+     * @param groupBy 分组维度:"day" | "week" | "month"
+     * @return List<Map> 每个时间点的数据:{date, revenue, orderCount, avgOrderValue}
+     */
+    public List<Map<String, Object>> getRevenueTrend(LocalDateTime startDate,
+                                                      LocalDateTime endDate,
+                                                      String groupBy) {
+        List<Order> orders = orderRepository.findByStatusAndPaidAtBetween("paid", startDate, endDate);
+
+        // Group by period key
+        Map<String, List<Order>> grouped = groupOrdersByPeriod(orders, groupBy);
+
+        return grouped.entrySet().stream()
+            .sorted(Map.Entry.comparingByKey())
+            .map(entry -> {
+                String period = entry.getKey();
+                List<Order> periodOrders = entry.getValue();
+                long revenue = periodOrders.stream()
+                    .filter(o -> o.getTotalFee() != null)
+                    .mapToLong(Order::getTotalFee)
+                    .sum();
+                int count = periodOrders.size();
+                long avg = count > 0 ? revenue / count : 0;
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("date", period);
+                row.put("revenue", revenue);
+                row.put("orderCount", count);
+                row.put("avgOrderValue", avg);
+                return row;
+            })
+            .collect(Collectors.toList());
+    }
+
+    private Map<String, List<Order>> groupOrdersByPeriod(List<Order> orders, String groupBy) {
+        DateTimeFormatter keyFmt;
+        switch (groupBy) {
+            case "week":
+                keyFmt = DateTimeFormatter.ofPattern("yyyy-'W'ww");  // e.g. 2026-W24
+                break;
+            case "month":
+                keyFmt = DateTimeFormatter.ofPattern("yyyy-MM");
+                break;
+            default: // day
+                keyFmt = DATE_FMT;
+        }
+        Map<String, List<Order>> grouped = new LinkedHashMap<>();
+        for (Order o : orders) {
+            if (o.getPaidAt() == null) continue;
+            String key = o.getPaidAt().format(keyFmt);
+            grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(o);
+        }
+        return grouped;
+    }
+
+    /**
+     * 用户增长时间序列。
+     * @return List<Map> {date, newUsers, newVip, newPractitioner}
+     */
+    public List<Map<String, Object>> getUserGrowthTrend(LocalDateTime startDate,
+                                                         LocalDateTime endDate,
+                                                         String groupBy) {
+        List<User> users = userRepository.findByCreatedAtBetweenOrderByCreatedAtAsc(startDate, endDate);
+
+        DateTimeFormatter keyFmt;
+        switch (groupBy) {
+            case "week":
+                keyFmt = DateTimeFormatter.ofPattern("yyyy-'W'ww");
+                break;
+            case "month":
+                keyFmt = DateTimeFormatter.ofPattern("yyyy-MM");
+                break;
+            default:
+                keyFmt = DATE_FMT;
+        }
+
+        Map<String, List<User>> grouped = new LinkedHashMap<>();
+        for (User u : users) {
+            if (u.getCreatedAt() == null) continue;
+            String key = u.getCreatedAt().format(keyFmt);
+            grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(u);
+        }
+
+        return grouped.entrySet().stream()
+            .sorted(Map.Entry.comparingByKey())
+            .map(e -> {
+                String period = e.getKey();
+                List<User> periodUsers = e.getValue();
+                int newUsers = periodUsers.size();
+                int newVip = (int) periodUsers.stream()
+                    .filter(u -> "annual".equals(u.getVipType()) || "super_annual".equals(u.getVipType()))
+                    .count();
+                int newPractitioner = (int) periodUsers.stream()
+                    .filter(u -> "practitioner".equals(u.getVipType()))
+                    .count();
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("date", period);
+                row.put("newUsers", newUsers);
+                row.put("newVip", newVip);
+                row.put("newPractitioner", newPractitioner);
+                return row;
+            })
+            .collect(Collectors.toList());
+    }
+
+    /**
+     * 核心指标汇总(支持日期范围)。
+     * @return Map{totalRevenue, totalOrders, totalUsers, newUsers, newVip, vipConversionRate, avgOrderValue}
+     */
+    public Map<String, Object> getSummaryStats(LocalDateTime startDate, LocalDateTime endDate) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+
+        // Revenue & Orders
+        List<Order> paidOrders = orderRepository.findByStatusAndPaidAtBetween("paid", startDate, endDate);
+        long totalRevenue = paidOrders.stream()
+            .filter(o -> o.getTotalFee() != null)
+            .mapToLong(Order::getTotalFee)
+            .sum();
+        int totalOrders = paidOrders.size();
+        long avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
+
+        // Users
+        long totalUsers = userRepository.count();
+        List<User> newUsersInRange = userRepository.findByCreatedAtBetweenOrderByCreatedAtAsc(startDate, endDate);
+        int newUsers = newUsersInRange.size();
+        int newVip = (int) newUsersInRange.stream()
+            .filter(u -> u.getVipType() != null && !"practitioner".equals(u.getVipType()))
+            .count();
+
+        // VIP conversion
+        long vipUsers = userRepository.countByVipEndTimeAfterAndVipEndTimeIsNotNull(LocalDateTime.now());
+        double vipRate = totalUsers > 0 ? (double) vipUsers / totalUsers * 100 : 0;
+
+        stats.put("totalRevenue", totalRevenue);
+        stats.put("totalOrders", totalOrders);
+        stats.put("avgOrderValue", avgOrderValue);
+        stats.put("totalUsers", totalUsers);
+        stats.put("newUsers", newUsers);
+        stats.put("newVip", newVip);
+        stats.put("vipConversionRate", Math.round(vipRate * 100) / 100.0);
+        stats.put("vipUsers", vipUsers);
+
+        return stats;
+    }
+
+    // ==================== Phase 2: 高级分析 ====================
+
+    /**
+     * 转化漏斗统计。
+     * @return Map{registered, paidC, paidSuper, practitioner, conversionRate}
+     */
+    public Map<String, Object> getConversionFunnel() {
+        Map<String, Object> funnel = new LinkedHashMap<>();
+
+        long totalUsers = userRepository.count();
+        long paidC = userRepository.findByVipType("annual").stream().count();
+        long paidSuper = userRepository.findByVipType("super_annual").stream().count();
+        long practitioner = userRepository.findByVipType("practitioner").stream().count();
+
+        double conversionRate = totalUsers > 0
+            ? Math.round((double) practitioner / totalUsers * 10000) / 100.0  // keep 2 decimal %
+            : 0;
+
+        funnel.put("registered", totalUsers);
+        funnel.put("paidC", paidC);
+        funnel.put("paidSuper", paidSuper);
+        funnel.put("practitioner", practitioner);
+        funnel.put("conversionRate", conversionRate);
+
+        return funnel;
+    }
+
+    /**
+     * 分销网络统计。
+     * @return Map{totalPromoters, topPromoters(List), totalCommission, avgCommission}
+     */
+    public Map<String, Object> getDistributionStats() {
+        Map<String, Object> stats = new LinkedHashMap<>();
+
+        // 有邀请记录的用户(推广者)
+        List<User> allUsers = userRepository.findAll();
+        List<User> promoters = allUsers.stream()
+            .filter(u -> u.getInvitedBy() != null && u.getInvitedBy() > 0)
+            .collect(Collectors.toList());
+        int totalPromoters = promoters.size();
+
+        // Top10 推广者(按 directCount + indirectCount 排序)
+        List<User> topPromoters = allUsers.stream()
+            .filter(u -> (u.getDirectCount() != null ? u.getDirectCount() : 0) > 0
+                     || (u.getIndirectCount() != null ? u.getIndirectCount() : 0) > 0)
+            .sorted((a, b) -> {
+                int scoreA = (a.getDirectCount() != null ? a.getDirectCount() : 0)
+                           + (a.getIndirectCount() != null ? a.getIndirectCount() : 0);
+                int scoreB = (b.getDirectCount() != null ? b.getDirectCount() : 0)
+                           + (b.getIndirectCount() != null ? b.getIndirectCount() : 0);
+                return Integer.compare(scoreB, scoreA);
+            })
+            .limit(10)
+            .collect(Collectors.toList());
+
+        // 转换为 Map 列表(避免返回 JPA 代理对象)
+        List<Map<String, Object>> topPromoterData = topPromoters.stream().map(u -> {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", u.getId());
+            m.put("nickname", u.getNickname() != null ? u.getNickname() : "微信用户");
+            m.put("avatarUrl", u.getAvatarUrl());
+            m.put("directCount", u.getDirectCount() != null ? u.getDirectCount() : 0);
+            m.put("indirectCount", u.getIndirectCount() != null ? u.getIndirectCount() : 0);
+            // 统计该用户已结算佣金
+            long userCommission = commissionRepository.findByToUserIdOrderByCreatedAtDesc(u.getId())
+                .stream()
+                .filter(c -> "settled".equals(c.getStatus()) || "available".equals(c.getStatus()))
+                .mapToLong(c -> c.getAmount() != null ? c.getAmount() : 0L)
+                .sum();
+            m.put("totalCommission", userCommission);
+            return m;
+        }).collect(Collectors.toList());
+
+        // 总佣金
+        long totalCommission = commissionRepository.findAll().stream()
+            .filter(c -> "settled".equals(c.getStatus()) || "available".equals(c.getStatus()))
+            .mapToLong(c -> c.getAmount() != null ? c.getAmount() : 0L)
+            .sum();
+        long avgCommission = totalPromoters > 0 ? totalCommission / totalPromoters : 0;
+
+        stats.put("totalPromoters", totalPromoters);
+        stats.put("topPromoters", topPromoterData);
+        stats.put("totalCommission", totalCommission);
+        stats.put("avgCommission", avgCommission);
+
+        return stats;
+    }
+
+    /**
+     * 提现统计。
+     */
+    public Map<String, Object> getWithdrawStats(LocalDateTime startDate, LocalDateTime endDate) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+
+        List<Withdraw> withdraws = withdrawRepository.findAll().stream()
+            .filter(w -> {
+                LocalDateTime created = w.getCreatedAt();
+                return created != null && !created.isBefore(startDate) && !created.isAfter(endDate);
+            })
+            .collect(Collectors.toList());
+
+        long totalAmount = withdraws.stream()
+            .filter(w -> w.getAmount() != null)
+            .mapToLong(Withdraw::getAmount)
+            .sum();
+
+        long successAmount = withdraws.stream()
+            .filter(w -> "success".equals(w.getStatus()))
+            .filter(w -> w.getAmount() != null)
+            .mapToLong(Withdraw::getAmount)
+            .sum();
+
+        long successCount = withdraws.stream().filter(w -> "success".equals(w.getStatus())).count();
+        double successRate = withdraws.size() > 0
+            ? Math.round((double) successCount / withdraws.size() * 10000) / 100.0
+            : 0;
+
+        // 按通道分组
+        Map<String, Long> byChannel = withdraws.stream()
+            .filter(w -> w.getChannel() != null && w.getAmount() != null)
+            .collect(Collectors.groupingBy(
+                Withdraw::getChannel,
+                Collectors.summingLong(Withdraw::getAmount)
+            ));
+
+        stats.put("totalAmount", totalAmount);
+        stats.put("successAmount", successAmount);
+        stats.put("successCount", successCount);
+        stats.put("successRate", successRate);
+        stats.put("byChannel", byChannel);
+        stats.put("totalCount", withdraws.size());
+
+        return stats;
+    }
+}