sql数据流,amdin业务逻辑接入
This commit is contained in:
@@ -11,11 +11,13 @@
|
||||
</view>
|
||||
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">选择时间:</text>
|
||||
<view class="date-picker-box">
|
||||
<text class="date-icon">📅</text>
|
||||
<text class="date-text">2026/01/04 - 2026/02/02</text>
|
||||
</view>
|
||||
<text class="filter-label">选择月份:</text>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="filter-btns">
|
||||
@@ -69,45 +71,203 @@
|
||||
<AnalyticsUserMapTable />
|
||||
</view>
|
||||
<view class="gender-col">
|
||||
<AnalyticsUserGenderSection />
|
||||
<AnalyticsUserGenderSection :startDate="startDate" :endDate="endDate" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
||||
import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.uvue'
|
||||
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
|
||||
import { getCurrentUser } from '@/utils/store.uts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
const kpiData = [
|
||||
{ title: '累计用户', value: '80834', percent: '0.84%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '访客数', value: '1138', percent: '1.04%', trend: 'down', icon: '👤', bg: '#e0f2fe' },
|
||||
{ title: '浏览量', value: '9519', percent: '2.34%', trend: 'down', icon: '👁️', bg: '#dcfce7' },
|
||||
{ title: '新增用户数', value: '680', percent: '4.36%', trend: 'down', icon: '👤', bg: '#ffedd5' },
|
||||
{ title: '成交用户数', value: '132', percent: '11.86%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '付费会员数', value: '79', percent: '7.05%', trend: 'down', icon: '💎', bg: '#f3e8ff' }
|
||||
]
|
||||
type KpiCard = {
|
||||
title: string
|
||||
value: string
|
||||
percent: string
|
||||
trend: string
|
||||
icon: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
x: ['01-04', '01-05', '01-06', '01-07', '01-08', '01-09', '01-10', '01-11', '01-12', '01-13', '01-14', '01-15', '01-16', '01-17', '01-18', '01-19', '01-20', '01-21', '01-22', '01-23', '01-24', '01-25', '01-26', '01-27', '01-28', '01-29', '01-30', '01-31', '02-01', '02-02'],
|
||||
type LineSeries = {
|
||||
name: string
|
||||
color: string
|
||||
data: Array<number>
|
||||
}
|
||||
|
||||
const selectedMonth = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const dateRangeText = computed((): string => {
|
||||
if (!startDate.value || !endDate.value) return '请选择月份'
|
||||
const fmt = (s: string): string => s.replace(/-/g, '/')
|
||||
return `${fmt(startDate.value)} - ${fmt(endDate.value)}`
|
||||
})
|
||||
|
||||
const kpiData = ref<Array<KpiCard>>([
|
||||
{ title: '累计用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '活跃用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#e0f2fe' },
|
||||
{ title: '新增用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#ffedd5' },
|
||||
{ title: '下单用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#dcfce7' },
|
||||
{ title: '支付用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '复购率', value: '-', percent: '-', trend: 'up', icon: '💎', bg: '#f3e8ff' }
|
||||
])
|
||||
|
||||
const chartData = ref<{ x: Array<string>; series: Array<LineSeries> }>({
|
||||
x: [],
|
||||
series: [
|
||||
{ name: '新增用户数', color: '#1890ff', data: [40, 30, 25, 30, 22, 10, 20, 32, 28, 15, 8, 12, 18, 22, 15, 12, 25, 30, 28, 25, 35, 20, 18, 22, 20, 15, 10, 8, 15, 38] },
|
||||
{ name: '访客数', color: '#52c41a', data: [70, 75, 65, 55, 65, 50, 45, 35, 50, 68, 72, 65, 50, 48, 55, 65, 75, 62, 58, 85, 70, 55, 48, 58, 65, 72, 68, 60, 45, 50] },
|
||||
{ name: '浏览量', color: '#fa8c16', data: [520, 500, 420, 280, 580, 180, 220, 100, 180, 450, 500, 400, 320, 340, 150, 280, 450, 320, 440, 460, 320, 260, 320, 280, 380, 400, 320, 330, 250, 300] },
|
||||
{ name: '成交用户数', color: '#722ed1', data: [15, 12, 10, 8, 18, 5, 8, 4, 6, 12, 15, 10, 8, 9, 4, 10, 12, 8, 10, 12, 8, 6, 10, 8, 12, 14, 10, 8, 5, 8] },
|
||||
{ name: '新增付费用户数', color: '#f5222d', data: [5, 4, 3, 2, 6, 1, 2, 1, 2, 4, 5, 3, 2, 3, 1, 3, 4, 2, 3, 4, 2, 2, 3, 2, 4, 5, 3, 2, 1, 3] }
|
||||
{ name: '新增用户数', color: '#1890ff', data: [] },
|
||||
{ name: '活跃用户', color: '#52c41a', data: [] }
|
||||
]
|
||||
})
|
||||
|
||||
function safeNumber(v: any): number {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function formatInt(v: any): string {
|
||||
const n = Math.round(safeNumber(v))
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
function formatPct(v: any): string {
|
||||
const n = safeNumber(v)
|
||||
const s = n.toFixed(2) + '%'
|
||||
return s
|
||||
}
|
||||
|
||||
function trendOf(pct: number): string {
|
||||
return pct >= 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
await ensureSupabaseReady()
|
||||
|
||||
if (!startDate.value || !endDate.value) {
|
||||
uni.showToast({ title: '请先选择月份', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const startStr = startDate.value
|
||||
const endStr = endDate.value
|
||||
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startStr)
|
||||
p.set('p_end_date', endStr)
|
||||
|
||||
const kRes: any = await supa.rpc('rpc_analytics_user_kpis', p)
|
||||
const row = Array.isArray(kRes.data) && kRes.data.length > 0 ? kRes.data[0] : (kRes.data || {})
|
||||
|
||||
const totalUsers = safeNumber(row.total_users)
|
||||
const totalGrowth = safeNumber(row.user_growth)
|
||||
const newUsers = safeNumber(row.new_users)
|
||||
const newGrowth = safeNumber(row.new_user_growth)
|
||||
const activeUsers = safeNumber(row.active_users)
|
||||
const activeGrowth = safeNumber(row.active_growth)
|
||||
const orderingUsers = safeNumber(row.ordering_users)
|
||||
const orderingGrowth = safeNumber(row.ordering_growth)
|
||||
const paidUsers = safeNumber(row.paid_users)
|
||||
const paidGrowth = safeNumber(row.paid_growth)
|
||||
const repurchaseRate = safeNumber(row.repurchase_rate)
|
||||
const repurchaseGrowth = safeNumber(row.repurchase_growth)
|
||||
|
||||
kpiData.value = [
|
||||
{ title: '累计用户', value: formatInt(totalUsers), percent: formatPct(totalGrowth), trend: trendOf(totalGrowth), icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '活跃用户', value: formatInt(activeUsers), percent: formatPct(activeGrowth), trend: trendOf(activeGrowth), icon: '👤', bg: '#e0f2fe' },
|
||||
{ title: '新增用户数', value: formatInt(newUsers), percent: formatPct(newGrowth), trend: trendOf(newGrowth), icon: '👤', bg: '#ffedd5' },
|
||||
{ title: '下单用户数', value: formatInt(orderingUsers), percent: formatPct(orderingGrowth), trend: trendOf(orderingGrowth), icon: '👤', bg: '#dcfce7' },
|
||||
{ title: '支付用户数', value: formatInt(paidUsers), percent: formatPct(paidGrowth), trend: trendOf(paidGrowth), icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '复购率', value: formatPct(repurchaseRate), percent: formatPct(repurchaseGrowth), trend: trendOf(repurchaseGrowth), icon: '💎', bg: '#f3e8ff' }
|
||||
]
|
||||
|
||||
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
|
||||
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
|
||||
|
||||
const x: Array<string> = []
|
||||
const newArr: Array<number> = []
|
||||
const activeArr: Array<number> = []
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
newArr.push(Number(rows[i].new_users) || 0)
|
||||
activeArr.push(Number(rows[i].active_users) || 0)
|
||||
}
|
||||
|
||||
chartData.value = {
|
||||
x,
|
||||
series: [
|
||||
{ name: '新增用户数', color: '#1890ff', data: newArr },
|
||||
{ name: '活跃用户', color: '#52c41a', data: activeArr }
|
||||
]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('admin user statistic loadData failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
uni.showToast({ title: '搜索中...' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onExport() {
|
||||
uni.showToast({ title: '导出中...' })
|
||||
uni.showToast({ title: '导出功能待接入', icon: 'none' })
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
startDate.value = range.start
|
||||
endDate.value = range.end
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
kpiData.value.forEach(item => {
|
||||
item.value = '-'
|
||||
item.percent = '-'
|
||||
})
|
||||
chartData.value.x = []
|
||||
chartData.value.series.forEach(s => s.data = [])
|
||||
}
|
||||
|
||||
// 初始化默认月份为当前月
|
||||
function initCurrentMonth() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
startDate.value = firstDay.toISOString().slice(0, 10)
|
||||
endDate.value = lastDay.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
if (!ensureAnalyticsLogin({ toastTitle: '请先登录以访问用户统计' })) return
|
||||
|
||||
const profile = await getCurrentUser()
|
||||
const role = profile?.role
|
||||
if (!role || !['admin', 'analytics'].includes(role)) {
|
||||
uni.showToast({ title: '权限不足', icon: 'none' })
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/mall/consumer/index' }), 800)
|
||||
return
|
||||
}
|
||||
|
||||
initCurrentMonth()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -222,51 +382,88 @@ function onExport() {
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
gap: 10px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.kpi-icon-box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.kpi-icon { font-size: 20px; }
|
||||
.kpi-icon { font-size: 14px; }
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-label { font-size: 14px; color: #8c8c8c; }
|
||||
.kpi-value { font-size: 24px; font-weight: 500; color: #262626; }
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-label { color: #8c8c8c; }
|
||||
.meta-label {
|
||||
color: #8c8c8c;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.meta-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-value.up { color: #ff4d4f; }
|
||||
.meta-value.down { color: #52c41a; }
|
||||
|
||||
|
||||
62
pages/mall/admin/user/docs/USER_STATISTICS_DB.md
Normal file
62
pages/mall/admin/user/docs/USER_STATISTICS_DB.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Admin / 用户统计 - 数据库接入说明
|
||||
|
||||
本页面(`pages/mall/admin/user/Statistic.uvue`)**复用** analytics 模块的 RPC 统计口径,不在 admin 模块重复定义。
|
||||
|
||||
## 依赖 RPC
|
||||
|
||||
- `public.rpc_analytics_user_kpis(p_start_date DATE, p_end_date DATE)`
|
||||
- `public.rpc_analytics_user_growth_trend(p_start_date DATE, p_end_date DATE)`
|
||||
- (可选扩展)
|
||||
- `public.rpc_analytics_user_segments(p_start_date DATE, p_end_date DATE)`
|
||||
- `public.rpc_analytics_traffic_sources(p_start_date DATE, p_end_date DATE)`
|
||||
|
||||
RPC 定义参考(权威):
|
||||
- `docs/sql/30_rpc/auth/get_current_user_role_v1.sql`(RPC 入口鉴权依赖)
|
||||
- `docs/sql/30_rpc/analytics/rpc_analytics_user_gender_distribution_v1.sql`(性别比例)
|
||||
|
||||
历史/迁移参考(非权威引用口径):
|
||||
- `mall_sql/migrations/ml_analytics_rpcs.sql`
|
||||
- `pages/mall/analytics/test/01_ml_analytics_rpcs_user.sql`
|
||||
|
||||
## 依赖数据表(口径来源)
|
||||
|
||||
- `public.ak_users`
|
||||
- `public.ml_orders`
|
||||
- `public.ml_browse_history`
|
||||
|
||||
## KPI 映射(页面展示 → RPC 返回字段)
|
||||
|
||||
页面 KPI(现 UI)建议映射如下:
|
||||
|
||||
- 累计用户 → `total_users`
|
||||
- 新增用户数 → `new_users`
|
||||
- 成交用户数 → `paid_users` 或 `ordering_users`
|
||||
- 如果你希望“成交”严格等于“支付成功用户”,用 `paid_users`
|
||||
- 如果你希望“成交”口径为“下单用户”,用 `ordering_users`
|
||||
|
||||
注意:当前 admin 页 UI 里存在“访客数/浏览量/付费会员数”等字段,但现有 `rpc_analytics_user_kpis` **并不直接返回**:
|
||||
|
||||
- 访客数、浏览量:需要基于埋点/访问日志表口径定义(可后续新增 RPC)
|
||||
- 付费会员数:需要会员/订阅表口径(如 `ml_user_subscriptions`)或会员字段
|
||||
|
||||
因此第一阶段接入会优先保证:
|
||||
- KPI:累计用户、新增用户、活跃用户、下单用户、支付用户、复购率等
|
||||
- 趋势:新增用户、活跃用户(与 analytics 对齐)
|
||||
|
||||
## SQL 验证文件
|
||||
|
||||
验证与调用样例:
|
||||
- `pages/mall/admin/user/test/01_ml_admin_user_statistics_verify.sql`
|
||||
|
||||
## 前端接入方式
|
||||
|
||||
建议使用与 analytics 同样的 Supabase RPC 调用方式:
|
||||
|
||||
- 使用 `ensureSupabaseReady()` 初始化
|
||||
- `supa.rpc('rpc_analytics_user_kpis', params)` 获取 KPI
|
||||
- `supa.rpc('rpc_analytics_user_growth_trend', params)` 获取趋势
|
||||
|
||||
params 示例:
|
||||
|
||||
- `p_start_date`: `YYYY-MM-DD`
|
||||
- `p_end_date`: `YYYY-MM-DD`
|
||||
@@ -0,0 +1,78 @@
|
||||
# 摘要
|
||||
|
||||
为 `pages/mall/admin/user/Statistic.uvue` 的“用户统计”页面接入数据库(复用 analytics RPC),并补齐“用户性别比例”模块的数据库接入。按项目规范新增模块内 `test/` 与 `docs/`,并将满足权威准入要求的性别比例 RPC 归档到 `docs/sql/30_rpc/analytics/`。
|
||||
|
||||
# 动机
|
||||
|
||||
- 现有 admin 用户统计页 KPI/趋势/性别比例均为静态数据,无法反映真实业务数据。
|
||||
- 按 `docs/AGENT_PROJECT_SPEC.md` 的两阶段 SQL 工作流:模块内测试 `test/` 先验证,满足安全准入后才进入 `docs/sql/` 权威目录。
|
||||
|
||||
# 影响范围
|
||||
|
||||
- **页面**:`pages/mall/admin/user/Statistic.uvue`
|
||||
- **组件**:`components/analytics/AnalyticsUserGenderSection.uvue`
|
||||
- **SQL(模块测试)**:`pages/mall/admin/user/test/*.sql`
|
||||
- **SQL(权威入库)**:`docs/sql/30_rpc/analytics/rpc_analytics_user_gender_distribution_v1.sql`
|
||||
|
||||
# 变更清单
|
||||
|
||||
## 新增文件
|
||||
|
||||
- `pages/mall/admin/user/test/01_ml_admin_user_statistics_verify.sql`
|
||||
- `pages/mall/admin/user/test/99_ml_admin_user_statistics_verify.sql`
|
||||
- `pages/mall/admin/user/test/02_ml_admin_user_gender_rpc.sql`(测试/验证脚本)
|
||||
- `pages/mall/admin/user/docs/USER_STATISTICS_DB.md`
|
||||
- `docs/sql/00_meta/README.md`
|
||||
- `docs/sql/10_schema/README.md`
|
||||
- `docs/sql/20_rls/README.md`
|
||||
- `docs/sql/30_rpc/README.md`
|
||||
- `docs/sql/40_grants/README.md`
|
||||
- `docs/sql/90_archive/README.md`
|
||||
- `docs/sql/30_rpc/analytics/rpc_analytics_user_gender_distribution_v1.sql`
|
||||
- `pages/mall/admin/user/docs/ops/2026-02-04__admin-user__user-statistics-db-and-gender-rpc.md`(本文件)
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `pages/mall/admin/user/Statistic.uvue`
|
||||
- KPI/趋势改为 RPC 动态加载
|
||||
- 月份范围筛选(当前月默认)
|
||||
- 将 `startDate/endDate` 透传给性别比例组件
|
||||
- `components/analytics/AnalyticsUserGenderSection.uvue`
|
||||
- 静态数据改为 RPC 动态加载
|
||||
- `pages/mall/admin/user/docs/USER_STATISTICS_DB.md`
|
||||
- 更新权威 RPC 引用路径
|
||||
|
||||
## 删除文件
|
||||
|
||||
- 无
|
||||
|
||||
# 兼容性与风险
|
||||
|
||||
- **RPC 权限风险**:权威 RPC 使用 `SECURITY DEFINER` 绕过 RLS,必须依赖 `public.get_current_user_role()` 做入口鉴权。
|
||||
- **数据口径风险**:当前 `ak_users.gender` 取值为 `male/other/NULL`,映射为 `男/未知`(并兼容 future `female`)。
|
||||
- **部署风险**:需确保数据库已执行权威 SQL(或在迁移流程中纳入)。
|
||||
|
||||
# 回滚方案
|
||||
|
||||
- 前端回滚:
|
||||
- 回滚 `pages/mall/admin/user/Statistic.uvue` 与 `components/analytics/AnalyticsUserGenderSection.uvue` 到改动前版本。
|
||||
- 数据库回滚:
|
||||
- 执行 `DROP FUNCTION IF EXISTS public.rpc_analytics_user_gender_distribution(DATE, DATE);`
|
||||
- 或将权威 RPC 文件从迁移/部署流程移除。
|
||||
|
||||
# 验证方式
|
||||
|
||||
1. **数据库验证**(管理员/analytics 身份):
|
||||
- 执行 `docs/sql/30_rpc/analytics/rpc_analytics_user_gender_distribution_v1.sql`
|
||||
- 调用:
|
||||
- `SELECT * FROM public.rpc_analytics_user_gender_distribution('2026-01-01','2026-01-31');`
|
||||
- 确认返回 `未知/男/女` 分布。
|
||||
|
||||
2. **前端验证**:
|
||||
- 打开 admin 用户统计页:KPI 与趋势显示为动态数据。
|
||||
- 选择月份后:KPI、趋势与“用户性别比例”同步刷新。
|
||||
|
||||
# 关联文档
|
||||
|
||||
- `docs/AGENT_PROJECT_SPEC.md`
|
||||
- `docs/sql/11_roles_and_permissions_strategy.md`
|
||||
26
pages/mall/admin/user/test/00_ml_admin_user_auth_verify.sql
Normal file
26
pages/mall/admin/user/test/00_ml_admin_user_auth_verify.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- =====================================================================================
|
||||
-- Admin 用户统计模块 - 权限与依赖函数验证(测试/回归)
|
||||
-- 目的:在部署/联调前快速确认 get_current_user_role 与性别分布 RPC 是否存在
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1) 检查 get_current_user_role 是否存在(权威依赖)
|
||||
SELECT n.nspname AS schema, p.proname AS name
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE p.proname = 'get_current_user_role'
|
||||
ORDER BY 1, 2;
|
||||
|
||||
-- 2) 检查性别分布 RPC 是否存在
|
||||
SELECT n.nspname AS schema, p.proname AS name
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE p.proname = 'rpc_analytics_user_gender_distribution'
|
||||
ORDER BY 1, 2;
|
||||
|
||||
-- 3) 字段检查:ak_users 是否存在 auth_id 与 role(供 get_current_user_role 使用)
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public'
|
||||
AND table_name='ak_users'
|
||||
AND column_name IN ('auth_id','role')
|
||||
ORDER BY column_name;
|
||||
@@ -0,0 +1,118 @@
|
||||
-- =====================================================================================
|
||||
-- Admin 用户统计模块 - RPC 验证与调用样例
|
||||
-- 说明: 本模块复用 analytics 模块的 RPC 函数,不重复定义。
|
||||
-- 本文件用于验证 RPC 可用性,并提供前端调用示例。
|
||||
-- 依赖: public.rpc_analytics_user_kpis, rpc_analytics_user_growth_trend 等
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1) 验证 RPC 函数是否存在
|
||||
SELECT routine_name, routine_type
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema = 'public'
|
||||
AND routine_name IN (
|
||||
'rpc_analytics_user_kpis',
|
||||
'rpc_analytics_user_growth_trend',
|
||||
'rpc_analytics_user_segments',
|
||||
'rpc_analytics_traffic_sources'
|
||||
)
|
||||
ORDER BY routine_name;
|
||||
|
||||
-- 2) 调用样例:获取用户统计 KPI(最近 7 天)
|
||||
-- 参数说明:
|
||||
-- p_start_date: 起始日期 (DATE)
|
||||
-- p_end_date: 结束日期 (DATE)
|
||||
-- 返回字段:
|
||||
-- total_users, user_growth, new_users, new_user_growth,
|
||||
-- active_users, active_growth, ordering_users, ordering_growth,
|
||||
-- paid_users, paid_growth, new_user_conversion_rate,
|
||||
-- repurchase_rate, repurchase_growth
|
||||
SELECT * FROM public.rpc_analytics_user_kpis(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
);
|
||||
|
||||
-- 3) 调用样例:获取用户增长与活跃趋势(最近 30 天,日维度)
|
||||
-- 参数说明:
|
||||
-- p_start_date: 起始日期 (DATE)
|
||||
-- p_end_date: 结束日期 (DATE)
|
||||
-- 返回字段:
|
||||
-- date, new_users, active_users
|
||||
SELECT * FROM public.rpc_analytics_user_growth_trend(
|
||||
CURRENT_DATE - INTERVAL '29 days',
|
||||
CURRENT_DATE
|
||||
)
|
||||
ORDER BY date;
|
||||
|
||||
-- 4) 调用样例:获取用户分群(新客/复购/老客)
|
||||
-- 参数说明:
|
||||
-- p_start_date: 起始日期 (DATE)
|
||||
-- p_end_date: 结束日期 (DATE)
|
||||
-- 返回字段:
|
||||
-- name, value
|
||||
SELECT * FROM public.rpc_analytics_user_segments(
|
||||
CURRENT_DATE - INTERVAL '29 days',
|
||||
CURRENT_DATE
|
||||
);
|
||||
|
||||
-- 5) 调用样例:获取渠道来源分布(基于注册来源)
|
||||
-- 参数说明:
|
||||
-- p_start_date: 起始日期 (DATE)
|
||||
-- p_end_date: 结束日期 (DATE)
|
||||
-- 返回字段:
|
||||
-- name, value
|
||||
SELECT * FROM public.rpc_analytics_traffic_sources(
|
||||
CURRENT_DATE - INTERVAL '29 days',
|
||||
CURRENT_DATE
|
||||
)
|
||||
ORDER BY value DESC;
|
||||
|
||||
-- 6) 综合验证:一次性调用所有 RPC 并检查返回是否正常
|
||||
DO $$
|
||||
DECLARE
|
||||
kpis RECORD;
|
||||
trend RECORD;
|
||||
seg RECORD;
|
||||
src RECORD;
|
||||
BEGIN
|
||||
-- 验证 KPI RPC
|
||||
FOR kpis IN SELECT * FROM public.rpc_analytics_user_kpis(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 1
|
||||
LOOP
|
||||
RAISE NOTICE 'rpc_analytics_user_kpis OK: total_users=%, new_users=%, active_users=%',
|
||||
kpis.total_users, kpis.new_users, kpis.active_users;
|
||||
END LOOP;
|
||||
|
||||
-- 验证趋势 RPC
|
||||
FOR trend IN SELECT * FROM public.rpc_analytics_user_growth_trend(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 1
|
||||
LOOP
|
||||
RAISE NOTICE 'rpc_analytics_user_growth_trend OK: date=%, new_users=%, active_users=%',
|
||||
trend.date, trend.new_users, trend.active_users;
|
||||
END LOOP;
|
||||
|
||||
-- 验证分群 RPC
|
||||
FOR seg IN SELECT * FROM public.rpc_analytics_user_segments(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 3
|
||||
LOOP
|
||||
RAISE NOTICE 'rpc_analytics_user_segments OK: name=%, value=%',
|
||||
seg.name, seg.value;
|
||||
END LOOP;
|
||||
|
||||
-- 验证渠道 RPC
|
||||
FOR src IN SELECT * FROM public.rpc_analytics_traffic_sources(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 3
|
||||
LOOP
|
||||
RAISE NOTICE 'rpc_analytics_traffic_sources OK: name=%, value=%',
|
||||
src.name, src.value;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '所有 RPC 验证通过。';
|
||||
END $$;
|
||||
95
pages/mall/admin/user/test/02_ml_admin_user_gender_rpc.sql
Normal file
95
pages/mall/admin/user/test/02_ml_admin_user_gender_rpc.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- =====================================================================================
|
||||
-- Admin 用户统计模块 - 性别比例 RPC(新增)
|
||||
-- 说明: 基于 ak_users.gender 字段统计性别分布,兼容“未知”情况
|
||||
-- 依赖: public.ak_users
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1) 检查 ak_users.gender 字段是否存在
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ak_users'
|
||||
AND column_name = 'gender'
|
||||
ORDER BY column_name;
|
||||
|
||||
-- 2) RPC: rpc_analytics_user_gender_distribution
|
||||
-- 参数: p_start_date, p_end_date (可选,用于统计周期内新增/活跃用户的性别分布)
|
||||
-- 返回: name, value (性别名称与人数)
|
||||
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_gender_distribution(
|
||||
p_start_date DATE DEFAULT NULL,
|
||||
p_end_date DATE DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
name TEXT,
|
||||
value BIGINT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
has_gender BOOLEAN := FALSE;
|
||||
where_clause TEXT := '';
|
||||
BEGIN
|
||||
-- 检查 gender 字段是否存在
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ak_users'
|
||||
AND column_name = 'gender'
|
||||
) INTO has_gender;
|
||||
|
||||
IF NOT has_gender THEN
|
||||
-- 如果没有 gender 字段,返回全“未知”
|
||||
RETURN QUERY
|
||||
SELECT '未知'::TEXT AS name,
|
||||
COUNT(*)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE (p_start_date IS NULL OR p_end_date IS NULL) OR
|
||||
(created_at::DATE BETWEEN COALESCE(p_start_date, '1970-01-01') AND COALESCE(p_end_date, CURRENT_DATE));
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 构建时间过滤条件
|
||||
IF p_start_date IS NOT NULL AND p_end_date IS NOT NULL THEN
|
||||
where_clause := ' AND created_at::DATE BETWEEN ''' || p_start_date::TEXT || ''' AND ''' || p_end_date::TEXT || '''';
|
||||
END IF;
|
||||
|
||||
-- 动态查询:按 gender 分组统计,兼容 NULL/空值
|
||||
RETURN QUERY EXECUTE '
|
||||
SELECT
|
||||
CASE
|
||||
WHEN gender IS NULL OR gender = '''' OR LOWER(TRIM(gender)) IN (''null'', ''unknown'', ''未知'') THEN ''未知''
|
||||
WHEN LOWER(TRIM(gender)) IN (''male'', ''m'', ''男'', ''1'') THEN ''男''
|
||||
WHEN LOWER(TRIM(gender)) IN (''female'', ''f'', ''女'', ''2'') THEN ''女''
|
||||
ELSE ''其他''
|
||||
END AS name,
|
||||
COUNT(*)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE 1=1' || where_clause || '
|
||||
GROUP BY name
|
||||
ORDER BY value DESC
|
||||
';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 3) 快速验证:调用 RPC(全量用户)
|
||||
SELECT * FROM public.rpc_analytics_user_gender_distribution();
|
||||
|
||||
-- 4) 验证:按月份调用(示例:2026-01)
|
||||
SELECT * FROM public.rpc_analytics_user_gender_distribution(
|
||||
'2026-01-01',
|
||||
'2026-01-31'
|
||||
);
|
||||
|
||||
-- 5) 数据完整性检查:查看当前 gender 字段的分布情况(用于调试)
|
||||
SELECT
|
||||
gender,
|
||||
COUNT(*) AS cnt,
|
||||
ROUND(COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM public.ak_users), 0), 2) AS pct
|
||||
FROM public.ak_users
|
||||
GROUP BY gender
|
||||
ORDER BY cnt DESC;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- =====================================================================================
|
||||
-- Admin 用户统计模块 - 完整验证脚本
|
||||
-- 说明: 验证 RPC 可用性 + 模拟前端调用参数,确保返回数据结构与前端期望一致
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1) 检查 RPC 是否已部署(与 analytics 共享)
|
||||
SELECT routine_name, routine_type
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema = 'public'
|
||||
AND routine_name IN (
|
||||
'rpc_analytics_user_kpis',
|
||||
'rpc_analytics_user_growth_trend',
|
||||
'rpc_analytics_user_segments',
|
||||
'rpc_analytics_traffic_sources'
|
||||
)
|
||||
ORDER BY routine_name;
|
||||
|
||||
-- 2) 模拟前端调用:最近 30 天(与页面默认一致)
|
||||
-- 2.1) KPI
|
||||
SELECT * FROM public.rpc_analytics_user_kpis(
|
||||
CURRENT_DATE - INTERVAL '29 days',
|
||||
CURRENT_DATE
|
||||
);
|
||||
|
||||
-- 2.2) 趋势(日维度)
|
||||
SELECT * FROM public.rpc_analytics_user_growth_trend(
|
||||
CURRENT_DATE - INTERVAL '29 days',
|
||||
CURRENT_DATE
|
||||
)
|
||||
ORDER BY date;
|
||||
|
||||
-- 3) 模拟前端调用:最近 7 天(快速验证)
|
||||
-- 3.1) KPI
|
||||
SELECT * FROM public.rpc_analytics_user_kpis(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
);
|
||||
|
||||
-- 3.2) 趋势
|
||||
SELECT * FROM public.rpc_analytics_user_growth_trend(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
)
|
||||
ORDER BY date;
|
||||
|
||||
-- 4) 检查依赖表是否存在
|
||||
SELECT table_name, table_type
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('ak_users', 'ml_orders', 'ml_browse_history')
|
||||
ORDER BY table_name;
|
||||
|
||||
-- 5) 检查关键字段是否存在(避免 RPC 运行时报错)
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ak_users'
|
||||
AND column_name IN ('id', 'created_at', 'registration_source')
|
||||
ORDER BY column_name;
|
||||
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ml_orders'
|
||||
AND column_name IN ('id', 'user_id', 'created_at', 'payment_status')
|
||||
ORDER BY column_name;
|
||||
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ml_browse_history'
|
||||
AND column_name IN ('user_id', 'created_at')
|
||||
ORDER BY column_name;
|
||||
|
||||
-- 6) 数据完整性检查(确保有数据可查)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM public.ak_users) AS ak_users_cnt,
|
||||
(SELECT COUNT(*) FROM public.ml_orders) AS ml_orders_cnt,
|
||||
(SELECT COUNT(*) FROM public.ml_browse_history) AS ml_browse_history_cnt;
|
||||
|
||||
-- 7) 快速验证脚本(返回是否可用)
|
||||
DO $$
|
||||
DECLARE
|
||||
kpis_ok BOOLEAN := FALSE;
|
||||
trend_ok BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- 验证 KPI RPC
|
||||
BEGIN
|
||||
PERFORM 1 FROM public.rpc_analytics_user_kpis(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 1;
|
||||
kpis_ok := TRUE;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
kpis_ok := FALSE;
|
||||
END;
|
||||
|
||||
-- 验证趋势 RPC
|
||||
BEGIN
|
||||
PERFORM 1 FROM public.rpc_analytics_user_growth_trend(
|
||||
CURRENT_DATE - INTERVAL '6 days',
|
||||
CURRENT_DATE
|
||||
) LIMIT 1;
|
||||
trend_ok := TRUE;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
trend_ok := FALSE;
|
||||
END;
|
||||
|
||||
IF kpis_ok AND trend_ok THEN
|
||||
RAISE NOTICE '✅ Admin 用户统计 RPC 验证通过';
|
||||
ELSE
|
||||
RAISE NOTICE '❌ Admin 用户统计 RPC 验证失败: kpis_ok=%, trend_ok=%', kpis_ok, trend_ok;
|
||||
END IF;
|
||||
END $$;
|
||||
45
pages/mall/admin/user/test/check_function_exists.sql
Normal file
45
pages/mall/admin/user/test/check_function_exists.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Check if the function exists and show all overloaded versions
|
||||
SELECT
|
||||
proname as function_name,
|
||||
pg_get_function_arguments(oid) as arguments,
|
||||
pg_get_functiondef(oid) as definition
|
||||
FROM pg_proc
|
||||
WHERE pronamespace = 'public'::regnamespace
|
||||
AND proname = 'rpc_analytics_user_kpis'
|
||||
ORDER BY oid;
|
||||
|
||||
-- Alternative: Check function existence with more details
|
||||
SELECT
|
||||
n.nspname as schema_name,
|
||||
p.proname as function_name,
|
||||
pg_get_function_arguments(p.oid) as arguments,
|
||||
pg_get_function_result(p.oid) as return_type,
|
||||
p.prokind as function_type,
|
||||
CASE p.prokind
|
||||
WHEN 'f' THEN 'Function'
|
||||
WHEN 'p' THEN 'Procedure'
|
||||
WHEN 'a' THEN 'Aggregate'
|
||||
WHEN 'w' THEN 'Window'
|
||||
END as kind
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'rpc_analytics_user_kpis'
|
||||
AND n.nspname = 'public'
|
||||
ORDER BY p.oid;
|
||||
|
||||
-- Check all functions in public schema
|
||||
SELECT
|
||||
proname as function_name,
|
||||
pg_get_function_arguments(oid) as arguments
|
||||
FROM pg_proc
|
||||
WHERE pronamespace = 'public'::regnamespace
|
||||
ORDER BY proname;
|
||||
|
||||
-- Quick existence check
|
||||
SELECT
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM pg_proc
|
||||
WHERE pronamespace = 'public'::regnamespace
|
||||
AND proname = 'rpc_analytics_user_kpis'
|
||||
) as function_exists;
|
||||
112
pages/mall/admin/user/test/rpc_admin_user_detail_v1.sql
Normal file
112
pages/mall/admin/user/test/rpc_admin_user_detail_v1.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_user_detail
|
||||
-- Version: v1
|
||||
-- Purpose: 管理后台用户详情(单个用户),返回完整资料+会员信息+地址
|
||||
-- Security: SECURITY DEFINER + 固定 search_path + 入口鉴权(admin/analytics)
|
||||
-- Depends:
|
||||
-- - public.ak_users (auth_id, email, username, role, created_at, updated_at)
|
||||
-- - public.ml_user_profiles (user_id, status, real_name, credit_score, verification_status, preferences, emergency_contact, service_areas)
|
||||
-- - public.ml_user_addresses (user_id, receiver_name, receiver_phone, province, city, district, address_detail, is_default)
|
||||
-- - public.ml_user_subscriptions (user_id, plan_id, status, start_date, end_date, next_billing_date, auto_renew)
|
||||
-- - public.ml_subscription_plans (id, name, billing_period, price, features)
|
||||
-- - public.get_current_user_role() (鉴权入口)
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_user_detail(
|
||||
p_user_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_detail JSONB;
|
||||
BEGIN
|
||||
-- 1) 入口鉴权:仅允许 admin/analytics 角色调用
|
||||
IF get_current_user_role() NOT IN ('admin', 'analytics') THEN
|
||||
RAISE EXCEPTION 'Permission denied: required role admin or analytics';
|
||||
END IF;
|
||||
|
||||
-- 2) 查询用户完整信息
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id,
|
||||
'auth_id', u.auth_id,
|
||||
'username', u.username,
|
||||
'email', u.email,
|
||||
'role', u.role,
|
||||
'profile_status', up.status,
|
||||
'real_name', up.real_name,
|
||||
'credit_score', up.credit_score,
|
||||
'verification_status', up.verification_status,
|
||||
'preferences', up.preferences,
|
||||
'emergency_contact', up.emergency_contact,
|
||||
'service_areas', up.service_areas,
|
||||
'created_at', u.created_at,
|
||||
'updated_at', u.updated_at,
|
||||
'balance', 0, -- 按要求固定返回 0
|
||||
'is_member', COALESCE(member_info.is_member, false),
|
||||
'member_info', member_info.subscription_detail,
|
||||
'addresses', address_info.addresses
|
||||
) INTO v_user_detail
|
||||
FROM public.ak_users u
|
||||
LEFT JOIN public.ml_user_profiles up ON u.id = up.user_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
true as is_member,
|
||||
jsonb_build_object(
|
||||
'plan_name', p.name,
|
||||
'plan_code', p.plan_code,
|
||||
'billing_period', p.billing_period,
|
||||
'price', p.price,
|
||||
'features', p.features,
|
||||
'status', s.status,
|
||||
'start_date', s.start_date,
|
||||
'end_date', s.end_date,
|
||||
'next_billing_date', s.next_billing_date,
|
||||
'auto_renew', s.auto_renew
|
||||
) as subscription_detail
|
||||
FROM public.ml_user_subscriptions s
|
||||
JOIN public.ml_subscription_plans p ON s.plan_id = p.id
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
ORDER BY s.end_date DESC NULLS FIRST
|
||||
LIMIT 1
|
||||
) member_info ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', a.id,
|
||||
'receiver_name', a.receiver_name,
|
||||
'receiver_phone', a.receiver_phone,
|
||||
'province', a.province,
|
||||
'city', a.city,
|
||||
'district', a.district,
|
||||
'address_detail', a.address_detail,
|
||||
'postal_code', a.postal_code,
|
||||
'is_default', a.is_default,
|
||||
'label', a.label,
|
||||
'latitude', a.latitude,
|
||||
'longitude', a.longitude,
|
||||
'delivery_instructions', a.delivery_instructions,
|
||||
'business_hours', a.business_hours,
|
||||
'status', a.status,
|
||||
'created_at', a.created_at,
|
||||
'updated_at', a.updated_at
|
||||
) ORDER BY a.is_default DESC, a.created_at DESC
|
||||
) as addresses
|
||||
FROM public.ml_user_addresses a
|
||||
WHERE a.user_id = u.id
|
||||
AND a.status = 1
|
||||
) address_info ON true
|
||||
WHERE u.id = p_user_id;
|
||||
|
||||
-- 3) 用户不存在时返回 NULL
|
||||
IF v_user_detail IS NULL THEN
|
||||
RETURN NULL::jsonb;
|
||||
END IF;
|
||||
|
||||
RETURN v_user_detail;
|
||||
END;
|
||||
$$;
|
||||
156
pages/mall/admin/user/test/rpc_admin_user_list_v1.sql
Normal file
156
pages/mall/admin/user/test/rpc_admin_user_list_v1.sql
Normal file
@@ -0,0 +1,156 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_user_list
|
||||
-- Version: v1
|
||||
-- Purpose: 管理后台用户列表(分页+筛选),返回基础资料+会员信息+余额(0)
|
||||
-- Security: SECURITY DEFINER + 固定 search_path + 入口鉴权(admin/analytics)
|
||||
-- Depends:
|
||||
-- - public.ak_users (auth_id, email, username, role, created_at, updated_at)
|
||||
-- - public.ml_user_profiles (user_id, status, real_name, credit_score, verification_status)
|
||||
-- - public.ml_user_subscriptions (user_id, plan_id, status, end_date)
|
||||
-- - public.ml_subscription_plans (id, name, billing_period, price)
|
||||
-- - public.get_current_user_role() (鉴权入口)
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_user_list(
|
||||
p_page INTEGER DEFAULT 1,
|
||||
p_limit INTEGER DEFAULT 20,
|
||||
p_search TEXT DEFAULT NULL,
|
||||
p_role TEXT DEFAULT NULL,
|
||||
p_status INTEGER DEFAULT NULL,
|
||||
p_is_member BOOLEAN DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
total BIGINT,
|
||||
page INTEGER,
|
||||
limit INTEGER,
|
||||
has_more BOOLEAN,
|
||||
items JSONB
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_offset INTEGER := (p_page - 1) * p_limit;
|
||||
v_total BIGINT;
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1) 入口鉴权:仅允许 admin/analytics 角色调用
|
||||
IF get_current_user_role() NOT IN ('admin', 'analytics') THEN
|
||||
RAISE EXCEPTION 'Permission denied: required role admin or analytics';
|
||||
END IF;
|
||||
|
||||
-- 2) 计算总数(用于分页)
|
||||
WITH filtered_users AS (
|
||||
SELECT u.id, u.auth_id, u.email, u.username, u.role,
|
||||
u.created_at, u.updated_at,
|
||||
up.status as profile_status,
|
||||
up.real_name,
|
||||
up.credit_score,
|
||||
up.verification_status
|
||||
FROM public.ak_users u
|
||||
LEFT JOIN public.ml_user_profiles up ON u.id = up.user_id
|
||||
WHERE 1=1
|
||||
AND (p_search IS NULL OR (
|
||||
u.username ILIKE '%' || p_search || '%' OR
|
||||
u.email ILIKE '%' || p_search || '%' OR
|
||||
up.real_name ILIKE '%' || p_search || '%'
|
||||
))
|
||||
AND (p_role IS NULL OR u.role = p_role)
|
||||
AND (p_status IS NULL OR up.status = p_status)
|
||||
AND (
|
||||
p_is_member IS NULL OR
|
||||
(p_is_member = TRUE AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.ml_user_subscriptions s
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
)) OR
|
||||
(p_is_member = FALSE AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.ml_user_subscriptions s
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
))
|
||||
)
|
||||
)
|
||||
SELECT COUNT(*) INTO v_total FROM filtered_users;
|
||||
|
||||
-- 3) 查询分页数据并组装会员信息
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', u.id,
|
||||
'auth_id', u.auth_id,
|
||||
'username', u.username,
|
||||
'email', u.email,
|
||||
'role', u.role,
|
||||
'profile_status', u.profile_status,
|
||||
'real_name', u.real_name,
|
||||
'credit_score', u.credit_score,
|
||||
'verification_status', u.verification_status,
|
||||
'created_at', u.created_at,
|
||||
'updated_at', u.updated_at,
|
||||
'balance', 0, -- 按要求固定返回 0
|
||||
'is_member', COALESCE(member_info.is_member, false),
|
||||
'member_plan_name', member_info.plan_name,
|
||||
'member_end_date', member_info.end_date
|
||||
)
|
||||
) INTO v_items
|
||||
FROM (
|
||||
SELECT u.*, up.status as profile_status, up.real_name, up.credit_score, up.verification_status
|
||||
FROM public.ak_users u
|
||||
LEFT JOIN public.ml_user_profiles up ON u.id = up.user_id
|
||||
WHERE 1=1
|
||||
AND (p_search IS NULL OR (
|
||||
u.username ILIKE '%' || p_search || '%' OR
|
||||
u.email ILIKE '%' || p_search || '%' OR
|
||||
up.real_name ILIKE '%' || p_search || '%'
|
||||
))
|
||||
AND (p_role IS NULL OR u.role = p_role)
|
||||
AND (p_status IS NULL OR up.status = p_status)
|
||||
AND (
|
||||
p_is_member IS NULL OR
|
||||
(p_is_member = TRUE AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.ml_user_subscriptions s
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
)) OR
|
||||
(p_is_member = FALSE AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.ml_user_subscriptions s
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
))
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT p_limit OFFSET v_offset
|
||||
) u
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
true as is_member,
|
||||
p.name as plan_name,
|
||||
s.end_date
|
||||
FROM public.ml_user_subscriptions s
|
||||
JOIN public.ml_subscription_plans p ON s.plan_id = p.id
|
||||
WHERE s.user_id = u.auth_id
|
||||
AND s.status IN ('trial', 'active')
|
||||
AND (s.end_date IS NULL OR s.end_date >= now())
|
||||
ORDER BY s.end_date DESC NULLS FIRST
|
||||
LIMIT 1
|
||||
) member_info ON true;
|
||||
|
||||
-- 4) 返回分页结构
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
v_total,
|
||||
p_page,
|
||||
p_limit,
|
||||
(v_offset + p_limit) < v_total as has_more,
|
||||
COALESCE(v_items, '[]'::jsonb) as items;
|
||||
END;
|
||||
$$;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user