sql数据流,amdin业务逻辑接入
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<text class="title">用户性别比例</text>
|
<text class="title">用户性别比例</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card-content">
|
<view class="card-content">
|
||||||
<view class="chart-container">
|
<view class="chart-container">
|
||||||
<EChartsView :option="chartOption" class="donut-chart" />
|
<EChartsView :option="chartOption" class="donut-chart" />
|
||||||
@@ -16,102 +16,155 @@
|
|||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="uts">
|
<script setup lang="uts">
|
||||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||||
|
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
export default {
|
type GenderRow = {
|
||||||
components: {
|
name: string
|
||||||
EChartsView
|
value: number
|
||||||
},
|
}
|
||||||
data() {
|
|
||||||
return {
|
const props = defineProps<{
|
||||||
totalUsers: 525,
|
startDate: string
|
||||||
chartOption: {} as any
|
endDate: string
|
||||||
}
|
}>()
|
||||||
},
|
|
||||||
mounted() {
|
const totalUsers = ref<number>(0)
|
||||||
setTimeout(() => {
|
const chartOption = ref<any>({})
|
||||||
this.initChart()
|
|
||||||
}, 200)
|
function toPlainObject(obj: any): any {
|
||||||
},
|
if (obj == null) return null
|
||||||
methods: {
|
if (typeof obj !== 'object') return obj
|
||||||
initChart() {
|
if (Array.isArray(obj)) {
|
||||||
const option = {
|
return obj.map((item) => toPlainObject(item))
|
||||||
tooltip: {
|
}
|
||||||
trigger: 'item',
|
const plain: any = {}
|
||||||
formatter: '{b}: {c} ({d}%)'
|
for (const key in obj) {
|
||||||
},
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
legend: {
|
const value = obj[key]
|
||||||
top: '0%',
|
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
|
||||||
left: 'center',
|
continue
|
||||||
icon: 'rect',
|
|
||||||
itemWidth: 15,
|
|
||||||
itemHeight: 15,
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '性别比例',
|
|
||||||
type: 'pie',
|
|
||||||
radius: ['50%', '75%'],
|
|
||||||
center: ['50%', '60%'],
|
|
||||||
avoidLabelOverlap: false,
|
|
||||||
label: {
|
|
||||||
show: false,
|
|
||||||
position: 'center'
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
label: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labelLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{ value: 450, name: '未知', itemStyle: { color: '#999999' } },
|
|
||||||
{ value: 50, name: '男', itemStyle: { color: '#3b82f6' } },
|
|
||||||
{ value: 25, name: '女', itemStyle: { color: '#f97316' } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
this.chartOption = this.toPlainObject(option)
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
},
|
let isSimple = true
|
||||||
toPlainObject(obj: any): any {
|
for (const k in value) {
|
||||||
if (obj == null) return null
|
if (typeof value[k] === 'object' && value[k] !== null) {
|
||||||
if (typeof obj !== 'object') return obj
|
isSimple = false
|
||||||
if (Array.isArray(obj)) {
|
break
|
||||||
return obj.map((item) => this.toPlainObject(item))
|
|
||||||
}
|
|
||||||
const plain: any = {}
|
|
||||||
for (const key in obj) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
||||||
const value = obj[key]
|
|
||||||
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
let isSimple = true
|
|
||||||
for (const k in value) {
|
|
||||||
if (typeof value[k] === 'object' && value[k] !== null) {
|
|
||||||
isSimple = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
|
|
||||||
} else {
|
|
||||||
plain[key] = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plain[key] = isSimple ? { ...value } : toPlainObject(value)
|
||||||
|
} else {
|
||||||
|
plain[key] = value
|
||||||
}
|
}
|
||||||
return plain
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return plain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initChartFromRows(rows: Array<GenderRow>) {
|
||||||
|
const normalized: any = { '未知': 0, '男': 0, '女': 0 }
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const name = `${rows[i].name}`
|
||||||
|
const value = Number(rows[i].value) || 0
|
||||||
|
if (name === '男' || name === '女') {
|
||||||
|
normalized[name] = value
|
||||||
|
} else {
|
||||||
|
normalized['未知'] += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalUsers.value = normalized['未知'] + normalized['男'] + normalized['女']
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: normalized['未知'], name: '未知', itemStyle: { color: '#999999' } },
|
||||||
|
{ value: normalized['男'], name: '男', itemStyle: { color: '#3b82f6' } },
|
||||||
|
{ value: normalized['女'], name: '女', itemStyle: { color: '#f97316' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: '{b}: {c} ({d}%)'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: '0%',
|
||||||
|
left: 'center',
|
||||||
|
icon: 'rect',
|
||||||
|
itemWidth: 15,
|
||||||
|
itemHeight: 15,
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '性别比例',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['50%', '75%'],
|
||||||
|
center: ['50%', '60%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
position: 'center'
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
data
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
chartOption.value = toPlainObject(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
if (!props.startDate || !props.endDate) {
|
||||||
|
totalUsers.value = 0
|
||||||
|
initChartFromRows([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureSupabaseReady()
|
||||||
|
|
||||||
|
const p = new UTSJSONObject()
|
||||||
|
p.set('p_start_date', props.startDate)
|
||||||
|
p.set('p_end_date', props.endDate)
|
||||||
|
|
||||||
|
const res: any = await supa.rpc('rpc_analytics_user_gender_distribution', p)
|
||||||
|
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||||
|
|
||||||
|
console.log('[AnalyticsUserGenderSection] props', props.startDate, props.endDate)
|
||||||
|
console.log('[AnalyticsUserGenderSection] rpc rows', rows)
|
||||||
|
|
||||||
|
initChartFromRows(rows as Array<GenderRow>)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AnalyticsUserGenderSection loadData failed', e)
|
||||||
|
totalUsers.value = 0
|
||||||
|
initChartFromRows([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const s = props.startDate
|
||||||
|
const e = props.endDate
|
||||||
|
if (s && e) {
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
totalUsers.value = 0
|
||||||
|
initChartFromRows([])
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
71
docs/ops/2026-02-04__db-schema__fix-ak-users-constraints.md
Normal file
71
docs/ops/2026-02-04__db-schema__fix-ak-users-constraints.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 操作文档:修复 `ak_users` 表写入协调问题
|
||||||
|
|
||||||
|
- **日期**: 2026-02-04
|
||||||
|
- **范围**: 数据库 Schema (`public.ak_users`)
|
||||||
|
- **标题**: 修复 `auth.users` -> `ak_users` 自动同步的写入协调问题
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
本操作通过修改 `public.ak_users` 表的结构,解决了新用户在 Supabase Auth 注册后,无法在业务表 `ak_users` 中自动创建对应记录的问题。核心改动是放宽了 `username` 和 `email` 字段的 `NOT NULL` 约束。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
在当前实现中,`auth.users` 表上存在一个 `on_auth_user_created` 触发器,该触发器在有新用户注册时会调用 `handle_new_user()` 函数,尝试在 `ak_users` 表中插入一条对应的业务用户记录。然而,由于 `ak_users` 表的 `username` 和 `email` 字段被设置为 `NOT NULL` 且没有默认值,而 `handle_new_user()` 函数在执行时无法提供这两个值,导致 `INSERT` 操作因违反约束而静默失败。这造成了 `auth.users` 和 `public.ak_users` 之间的数据不一致,使得新注册的用户无法被业务系统识别,后续的权限、角色等功能全部失效。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- **数据库表**: `public.ak_users`
|
||||||
|
- **数据库函数**: `public.handle_new_user()` (现在可以成功执行)
|
||||||
|
- **前端逻辑**: `utils/sapi.uts` 中的 `ensureUserProfile` 函数(现在在用户首次登录时,即使触发器失败,它也能成功插入记录)。
|
||||||
|
- **业务流程**: 新用户注册、首次登录的数据同步流程。
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
- **新增文件**:
|
||||||
|
- `docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql`: 包含了本次 DDL 变更的权威 SQL 脚本。
|
||||||
|
- `docs/ops/2026-02-04__db-schema__fix-ak-users-constraints.md`: 本操作文档。
|
||||||
|
|
||||||
|
- **修改内容**: `public.ak_users` 表结构
|
||||||
|
- `username` 字段的 `NOT NULL` 约束被移除。
|
||||||
|
- `email` 字段的 `NOT NULL` 约束被移除。
|
||||||
|
- `role` 字段的默认值被更新为 `'customer'`。
|
||||||
|
|
||||||
|
## 兼容性与风险
|
||||||
|
|
||||||
|
- **数据兼容性**: 此变更为向下兼容。允许 `username` 和 `email` 为空,不会影响现有记录。应用层代码(如 `ensureUserProfile`)会在用户首次登录时尝试填充这些值。
|
||||||
|
- **风险**: 理论上,现在 `ak_users` 表中可能存在 `username` 或 `email` 为空的记录。依赖这两个字段为非空的前端页面或业务逻辑需要做好空值处理,但这符合用户首次登录后才完善资料的常见模式。
|
||||||
|
|
||||||
|
## 回滚方案
|
||||||
|
|
||||||
|
如果需要撤销此变更,可以执行以下 SQL 命令,将表的约束恢复到之前的状态:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.ak_users
|
||||||
|
ALTER COLUMN username SET NOT NULL,
|
||||||
|
ALTER COLUMN email SET NOT NULL;
|
||||||
|
|
||||||
|
-- 注意:回滚前必须确保表中没有 username 或 email 为 NULL 的记录,否则会失败。
|
||||||
|
|
||||||
|
-- 将 role 的默认值恢复为 'student'(如果需要)
|
||||||
|
ALTER TABLE public.ak_users
|
||||||
|
ALTER COLUMN role SET DEFAULT 'student';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. **执行变更**: 在数据库中执行 `docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql` 的内容。
|
||||||
|
2. **注册新用户**: 在 App 中注册一个全新的用户(例如 `new_user@example.com`)。
|
||||||
|
3. **验证 `ak_users` 表**: 在 SQL Editor 中查询,确认 `ak_users` 表中已自动创建了与新用户 `auth_id` 对应的记录。
|
||||||
|
```sql
|
||||||
|
SELECT auth_id, email, username, role FROM public.ak_users WHERE email = 'new_user@example.com';
|
||||||
|
```
|
||||||
|
4. **验证 `consistency_status`**: 再次执行之前的一致性检查 SQL,确认新用户的状态为 `CONSISTENT`。
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/AGENT_PROJECT_SPEC.md`
|
||||||
|
- `docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql`
|
||||||
56
docs/ops/2026-02-04__db-trigger__fix-handle-new-user-sync.md
Normal file
56
docs/ops/2026-02-04__db-trigger__fix-handle-new-user-sync.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 操作文档:修复 `handle_new_user` 触发器函数
|
||||||
|
|
||||||
|
- **日期**: 2026-02-04
|
||||||
|
- **范围**: 数据库触发器函数 (`public.handle_new_user`)
|
||||||
|
- **标题**: 修复 `auth.users` -> `public.ak_users` 的自动用户同步逻辑
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
本操作通过更新 `public.handle_new_user()` 触发器函数的定义,解决了新用户在 Supabase Auth 注册后,无法在核心业务表 `public.ak_users` 中自动创建对应记录的根本问题。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
经过排查,发现 `on_auth_user_created` 触发器调用的 `handle_new_user()` 函数并未向 `public.ak_users` 表执行任何 `INSERT` 操作,而是错误地将数据写入了 `public.user_roles` 和 `auth.users.raw_user_meta_data`。这导致 `auth.users` 和 `public.ak_users` 之间的数据完全脱节,使得所有依赖 `ak_users.role` 的权限校验(如 RPC 鉴权)全部失败。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- **数据库函数**: `public.handle_new_user()`
|
||||||
|
- **数据库触发器**: `on_auth_user_created` (在 `auth.users` 表上)
|
||||||
|
- **数据库表**: `public.ak_users` (现在可以被自动写入), `public.user_roles` (保持兼容写入)
|
||||||
|
- **业务流程**: 新用户注册的数据同步流程。
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
- **新增文件**:
|
||||||
|
- `docs/sql/30_rpc/auth/handle_new_user_v2.sql`: 包含了修正后的 `handle_new_user` 函数的权威 SQL 脚本。
|
||||||
|
- `docs/ops/2026-02-04__db-trigger__fix-handle-new-user-sync.md`: 本操作文档。
|
||||||
|
|
||||||
|
- **修改内容**: `public.handle_new_user()` 函数的定义
|
||||||
|
- **新增**: 向 `public.ak_users` 表 `INSERT` 新用户记录的逻辑,包含 `auth_id`, `email`, `username`, 和 `role`。
|
||||||
|
- **新增**: 使用 `ON CONFLICT` 确保操作的幂等性。
|
||||||
|
- **保留**: 保留了向 `public.user_roles` 和 `auth.users.raw_user_meta_data` 的写入,以兼容现有逻辑。
|
||||||
|
- **安全**: 函数已遵循 `SECURITY DEFINER` 和 `SET search_path = public` 的安全规范。
|
||||||
|
|
||||||
|
## 兼容性与风险
|
||||||
|
|
||||||
|
- **数据兼容性**: 此变更为向前兼容。它修复了新用户的数据同步问题,不会影响现有的 `ak_users` 记录。
|
||||||
|
- **风险**: 无明显风险。函数现在会正确地执行其预期的核心任务。
|
||||||
|
|
||||||
|
## 回滚方案
|
||||||
|
|
||||||
|
如果需要撤销此变更,可以从版本控制(Git)中找回旧的 `handle_new_user()` 函数定义,或者从数据库备份中恢复,然后重新执行 `CREATE OR REPLACE FUNCTION` 即可。
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. **执行变更**: 在数据库中执行 `docs/sql/30_rpc/auth/handle_new_user_v2.sql` 的内容,以更新函数定义。
|
||||||
|
2. **注册新用户**: 在 App 中注册一个全新的用户(例如 `final_test@example.com`)。
|
||||||
|
3. **验证 `ak_users` 表**: 在 SQL Editor 中查询,确认 `ak_users` 表中已自动创建了与新用户 `auth_id` 对应的记录,并且 `role` 已根据邮箱规则(或默认为 `customer`)被正确设置。
|
||||||
|
```sql
|
||||||
|
SELECT auth_id, email, username, role FROM public.ak_users WHERE email = 'final_test@example.com';
|
||||||
|
```
|
||||||
|
4. **验证一致性**: 再次执行一致性检查 SQL,确认新用户的 `consistency_status` 为 `CONSISTENT`。
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/AGENT_PROJECT_SPEC.md`
|
||||||
|
- `docs/sql/30_rpc/auth/handle_new_user_v2.sql`
|
||||||
73
docs/ops/2026-02-05__auth__fix-login-registration-sync.md
Normal file
73
docs/ops/2026-02-05__auth__fix-login-registration-sync.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 操作文档:修复登录注册与数据同步全链路问题
|
||||||
|
|
||||||
|
- **日期**: 2026-02-05
|
||||||
|
- **范围**: 用户认证、注册、数据库同步及页面守卫
|
||||||
|
- **标题**: 修复因“写入协调”失败导致的 Admin 功能权限问题
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
本操作彻底解决了新用户注册后,因数据库写入协调失败,导致业务用户表 (`public.ak_users`) 记录缺失,进而使得所有需要 `admin`/`analytics` 角色的 RPC 调用和页面访问全部失败的问题。修复遵循 `@docs/AGENT_PROJECT_SPEC.md` 规范,涉及数据库 DDL、触发器函数、前端服务和页面守卫的全链路调整。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
- **直接问题**: Admin 后台的统计图表始终显示为 0,即使在数据库中有数据。
|
||||||
|
- **根本原因**: 前端 RPC 调用因权限不足被拒绝。
|
||||||
|
- **深层原因**: 权限检查依赖的 `get_current_user_role()` 函数因在 `ak_users` 表中找不到当前用户的 `auth_id` 而返回 `NULL`。
|
||||||
|
- **最终根源**: 新用户在 `auth.users` 表注册后,自动同步到 `ak_users` 表的数据库触发器 `handle_new_user()` 函数逻辑错误(未向 `ak_users` 写入),导致数据不一致。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- **数据库**: `public.ak_users` 表结构, `public.handle_new_user()` 函数, `auth.users` 表上的 `on_auth_user_created` 触发器。
|
||||||
|
- **前端服务**: `utils/sapi.uts` (`ensureUserProfile`), `utils/store.uts` (`getCurrentUser`), `services/analytics/authGuard.uts`.
|
||||||
|
- **前端页面**: `pages/user/login.uvue`, `pages/user/register.uvue`, `layouts/admin/AdminLayout.uvue`.
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
### 数据库侧 (权威 SQL)
|
||||||
|
|
||||||
|
1. **修复 `ak_users` 表结构约束**:
|
||||||
|
- **文件**: `docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql`
|
||||||
|
- **内容**: 移除了 `username` 和 `email` 的 `NOT NULL` 约束,以确保自动同步不会因缺少值而失败。
|
||||||
|
|
||||||
|
2. **修复 `handle_new_user` 触发器函数**:
|
||||||
|
- **文件**: `docs/sql/30_rpc/auth/handle_new_user_v3.sql`
|
||||||
|
- **内容**: 将函数逻辑修正为正确地向 `public.ak_users` 表 `INSERT` 新用户记录,并兼容写入 `public.user_roles`。
|
||||||
|
|
||||||
|
3. **创建 `get_current_user_role` 函数**:
|
||||||
|
- **文件**: `docs/sql/30_rpc/auth/get_current_user_role_v1.sql`
|
||||||
|
- **内容**: 创建了用于 RPC 鉴权的权威函数,通过 `auth_id` 查询 `ak_users` 表获取角色。
|
||||||
|
|
||||||
|
### 前端侧
|
||||||
|
|
||||||
|
1. **修复 `ensureUserProfile` 写入逻辑**:
|
||||||
|
- **文件**: `utils/sapi.uts`
|
||||||
|
- **内容**: 修正了 `INSERT` 逻辑,确保写入的是 `auth_id` 而不是业务主键 `id`,并设置了默认角色。
|
||||||
|
|
||||||
|
2. **修复登录逻辑**:
|
||||||
|
- **文件**: `pages/user/login.uvue`
|
||||||
|
- **内容**: 移除了生产环境下的 `admin/admin` 本地绕过登录,强制所有登录走 Supabase Auth 以获取有效 Token。并在登录成功后调用 `ensureUserProfile`。
|
||||||
|
|
||||||
|
3. **实现统一页面守卫**:
|
||||||
|
- **文件**: `layouts/admin/AdminLayout.uvue`
|
||||||
|
- **内容**: 在布局的 `onMounted` 钩子中加入了统一的权限守卫,覆盖所有使用该布局的 Admin 页面。
|
||||||
|
|
||||||
|
## 最终解决方案与验证流程
|
||||||
|
|
||||||
|
1. **数据库部署**: 按顺序执行以下权威 SQL 文件:
|
||||||
|
- `docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql`
|
||||||
|
- `docs/sql/30_rpc/auth/get_current_user_role_v1.sql`
|
||||||
|
- `docs/sql/30_rpc/auth/handle_new_user_v3.sql`
|
||||||
|
- `ALTER TABLE auth.users ENABLE TRIGGER on_auth_user_created;` (确保触发器是启用的)
|
||||||
|
|
||||||
|
2. **新用户注册**: 在 App 中注册一个全新的用户。
|
||||||
|
|
||||||
|
3. **验证数据同步**: 在 SQL Editor 中执行“三表一致性”查询,确认新用户的 `status` 为 `OK` 或至少 `ak_users` 不再 `MISSING`。
|
||||||
|
|
||||||
|
4. **提升权限**: 手动将新用户在 `public.ak_users` 表中的 `role` 字段更新为 `'admin'`。
|
||||||
|
|
||||||
|
5. **登录并访问**: 使用新注册并已提权的账号登录 App,访问 Admin 后台的统计页面,确认数据能正常加载。
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/AGENT_PROJECT_SPEC.md`
|
||||||
|
- `docs/sql/11_roles_and_permissions_strategy.md`
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
# 2026-02-05 / mall:配送-商家-消费者 数据流统一分析(SQL + 项目规范口径)
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
本文基于本仓库 **权威 SQL 目录** `docs/sql/`(并参考 `mall_sql/` 中的 `complete_mall_database.sql` 实现细节),结合项目工程与安全规范 `docs/project_spec/AGENT_PROJECT_SPEC.md`、`docs/sql/11_roles_and_permissions_strategy.md`,对 **消费者 / 商家 / 配送员** 三方在电商场景中的数据流运转进行统一分析。
|
||||||
|
|
||||||
|
输出内容包括:
|
||||||
|
|
||||||
|
- 三方域模型与关键表关系(文字版 ER)
|
||||||
|
- 端到端事件链路:下单、支付、履约配送、完成、评价
|
||||||
|
- 订单与配送状态机(字段口径与关键歧义点)
|
||||||
|
- 读模型(各端典型查询视角)
|
||||||
|
- 权限边界(RLS / RPC / 前端守卫)在本项目中的统一口径
|
||||||
|
|
||||||
|
> 本文为分析文档,不包含代码/SQL 变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
- 统一“谁能看/改什么数据”的口径:角色权威字段、RLS 与 RPC 的安全闭环要求必须一致。
|
||||||
|
- 统一“数据如何落表与流转”的口径:避免前后端/不同模块对订单、配送、商家模型理解不一致。
|
||||||
|
- 为后续落地(配置页、配送履约闭环、统计看板等)提供可审计的基础说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- **数据库层**:`docs/sql/` 中的表结构、触发器、RLS、RPC 策略口径(本文不修改)。
|
||||||
|
- **前端工程层**:页面必须通过 `services/` 访问数据;admin/analytics 需通过 RPC 获取全局数据(本文不修改)。
|
||||||
|
- **业务模块**:
|
||||||
|
- 消费者端(consumer)订单链路
|
||||||
|
- 商家端(merchant)商品与订单履约
|
||||||
|
- 配送端(delivery)接单/取货/送达
|
||||||
|
- 管理端(admin)配置与全局视角
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
- **新增文件**:
|
||||||
|
- `docs/ops/2026-02-05__mall__delivery-merchant-customer-dataflow-analysis.md`
|
||||||
|
- **修改文件**:无
|
||||||
|
- **删除文件**:无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 权威输入(引用口径)
|
||||||
|
|
||||||
|
- 数据库与流程说明:
|
||||||
|
- `docs/sql/02_relationships_er.md`
|
||||||
|
- `docs/sql/03_enums_status_dict.md`
|
||||||
|
- `docs/sql/07_business_workflows.md`
|
||||||
|
- `docs/sql/11_roles_and_permissions_strategy.md`
|
||||||
|
- 工程与安全规范:
|
||||||
|
- `docs/project_spec/AGENT_PROJECT_SPEC.md`
|
||||||
|
- 参考实现(非权威口径,但含完整 DDL/触发器/RLS 示例):
|
||||||
|
- `mall_sql/schemas/complete_mall_database.sql`
|
||||||
|
- 管理端设置页现状(占位):
|
||||||
|
- `pages/mall/admin/system-settings.uvue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 统一域模型:三方是谁(同一身份体系下的三类角色)
|
||||||
|
|
||||||
|
- **统一用户主表**:`public.ak_users`
|
||||||
|
- **角色唯一权威字段**:`ak_users.role`
|
||||||
|
- **商城用户扩展档案**:`public.ml_user_profiles`
|
||||||
|
- 关系:`ak_users` 1:1 `ml_user_profiles`(`ml_user_profiles.user_id UNIQUE`)
|
||||||
|
|
||||||
|
三方角色定义(业务口径):
|
||||||
|
|
||||||
|
- 消费者:`ak_users.role = 'customer'`
|
||||||
|
- 商家:`ak_users.role = 'merchant'`
|
||||||
|
- 配送员:`ak_users.role = 'delivery'`
|
||||||
|
|
||||||
|
结论:三方并非三套用户体系,而是“同一 `ak_users` + 不同域表扩展”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心表关系(文字版 ER)
|
||||||
|
|
||||||
|
### 2.1 消费者(Customer)
|
||||||
|
|
||||||
|
- `ak_users (customer)` 1:N `ml_user_addresses`
|
||||||
|
- `ak_users (customer)` 1:N `ml_shopping_cart`
|
||||||
|
- `ak_users (customer)` 1:N `ml_orders`(`ml_orders.user_id`)
|
||||||
|
|
||||||
|
### 2.2 商家(Merchant)
|
||||||
|
|
||||||
|
- `ak_users (merchant)` 1:1 `ml_shops`(`ml_shops.merchant_id UNIQUE`)
|
||||||
|
- 当前模型含义:一商家一店铺
|
||||||
|
- `ak_users (merchant)` 1:N `ml_products`(`ml_products.merchant_id`)
|
||||||
|
- `ak_users (merchant)` 1:N `ml_orders`(`ml_orders.merchant_id`)
|
||||||
|
|
||||||
|
### 2.3 商品(Product)
|
||||||
|
|
||||||
|
- `ml_products` 1:N `ml_product_skus`
|
||||||
|
- `ml_products` 1:N `ml_product_specs`
|
||||||
|
- SKU 变更触发汇总:`ml_product_skus` 变更 -> 触发器刷新 `ml_products.total_stock/available_stock`
|
||||||
|
|
||||||
|
### 2.4 订单(Order)
|
||||||
|
|
||||||
|
- `ml_orders` 1:N `ml_order_items`
|
||||||
|
- `ml_orders` 1:1 `ml_delivery_tasks`(`ml_delivery_tasks.order_id UNIQUE`)
|
||||||
|
- 当前模型含义:一订单最多一个配送任务
|
||||||
|
|
||||||
|
### 2.5 配送(Delivery)
|
||||||
|
|
||||||
|
- `ak_users (delivery)` 1:1 `ml_delivery_drivers`(`ml_delivery_drivers.user_id UNIQUE`)
|
||||||
|
- `ml_delivery_drivers` 1:N `ml_delivery_tasks`(通过 `ml_delivery_tasks.driver_id` 关联)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三方端到端数据流(事件 → 落表 → 状态变化)
|
||||||
|
|
||||||
|
### 3.1 消费者链路(浏览→加购→下单→支付→收货→评价)
|
||||||
|
|
||||||
|
#### 事件 A:维护地址
|
||||||
|
|
||||||
|
- 写表:`ml_user_addresses`
|
||||||
|
- 约束:同一用户最多一个默认地址
|
||||||
|
- 由触发器 `ensure_single_default_address()` 保证
|
||||||
|
|
||||||
|
#### 事件 B:加购/改购
|
||||||
|
|
||||||
|
- 写表:`ml_shopping_cart`
|
||||||
|
- 约束:`UNIQUE(user_id, product_id, sku_id)`(便于 upsert 累加)
|
||||||
|
|
||||||
|
#### 事件 C:下单(创建订单 + 明细快照)
|
||||||
|
|
||||||
|
- 写表:
|
||||||
|
- `ml_orders`(订单主表)
|
||||||
|
- `ml_order_items`(订单明细快照)
|
||||||
|
- 关键设计点:
|
||||||
|
- `ml_orders.shipping_address` 为 JSONB 地址快照,避免订单与地址簿耦合
|
||||||
|
- `ml_order_items` 快照化写入商品名/规格/图/价格,避免商品变更影响历史
|
||||||
|
|
||||||
|
> 一致性提示(现状):当前权威文档指出未体现“扣库存/冻结库存”落地,库存一致性需由应用层事务或后续 DB/RPC 补齐。
|
||||||
|
|
||||||
|
#### 事件 D:支付成功
|
||||||
|
|
||||||
|
- 更新:`ml_orders`
|
||||||
|
- `order_status: 1 -> 2`
|
||||||
|
- `payment_status: 1 -> 2`
|
||||||
|
- `paid_amount = total_amount`
|
||||||
|
- 触发器副作用:`handle_order_status_change()` 在 `1->2` 写 `paid_at`
|
||||||
|
|
||||||
|
#### 事件 E:收货完成/订单完成
|
||||||
|
|
||||||
|
- 更新:`ml_orders`
|
||||||
|
- `order_status: 3 -> 4`
|
||||||
|
- `shipping_status -> 4`
|
||||||
|
- 触发器副作用:写 `delivered_at/completed_at`,并累计更新 `ml_products.sale_count`
|
||||||
|
|
||||||
|
#### 事件 F:评价
|
||||||
|
|
||||||
|
- 写表:`ml_product_reviews`
|
||||||
|
- 强绑定来源:`order_id + order_item_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.1.1 退款/取消在当前模型中的最小表达(现状与缺口)
|
||||||
|
|
||||||
|
当前数据库没有独立的退款申请表或售后流程表,只能通过 `ml_orders` 的状态组合表达:
|
||||||
|
|
||||||
|
- **退款分支**:
|
||||||
|
- `payment_status: 3(部分退款)` / `4(全额退款)`
|
||||||
|
- `order_status` 通常进入 `6(退款中)` -> `7(已退款)`
|
||||||
|
- **取消分支**:
|
||||||
|
- `order_status: 5`(歧义点,见第 4.2 节)
|
||||||
|
- `cancel_reason`(文本字段)可用于记录取消原因
|
||||||
|
|
||||||
|
> **缺口提示**:
|
||||||
|
> - 缺少退款申请流水、审批流、支付对账表,复杂售后需后续扩表或接支付系统。
|
||||||
|
> - 缺少 `cancelled_at`、`refunded_at` 等审计时间字段。
|
||||||
|
> - 配送已取货/在途/已送达时的退款/取消口径未在当前模型体现(需业务层定义)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 商家链路(供给→接单→履约)
|
||||||
|
|
||||||
|
#### 事件 A:创建/编辑商品
|
||||||
|
|
||||||
|
- 写表:`ml_products`、`ml_product_skus`、`ml_product_specs`
|
||||||
|
- 库存汇总:SKU 触发器刷新 SPU 汇总库存字段
|
||||||
|
|
||||||
|
#### 事件 B:发货/履约推进
|
||||||
|
|
||||||
|
- 更新:`ml_orders`
|
||||||
|
- `order_status: 2 -> 3`
|
||||||
|
- `shipping_status: 1 -> 2`
|
||||||
|
- 触发器副作用:`2->3` 时写 `shipped_at`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2.1 单商家订单模型的限制(需求提醒)
|
||||||
|
|
||||||
|
当前订单主表 `ml_orders` 直接持有 `merchant_id`,且未见“主单/子单”结构,因此该模型天然对应:
|
||||||
|
|
||||||
|
- **一笔订单仅归属一个商家/店铺**(单商家订单)
|
||||||
|
|
||||||
|
> 若产品需求为“一单多商家(多店铺)”,通常需要:
|
||||||
|
> - 引入主/子订单拆分(例如主订单聚合支付与收货信息,子订单按商家拆分履约与结算),或
|
||||||
|
> - 在下单阶段强制拆单(多个 `ml_orders`)并建立父子关联字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 配送链路(派单/接单→取货→配送→送达)
|
||||||
|
|
||||||
|
#### 事件 A:创建配送任务
|
||||||
|
|
||||||
|
- 写表:`ml_delivery_tasks`
|
||||||
|
- 关键字段:
|
||||||
|
- `order_id UNIQUE`
|
||||||
|
- `driver_id` 可空(先生成后指派)
|
||||||
|
- `pickup_address`(取货点)
|
||||||
|
- `delivery_address`(配送点,建议源自订单地址快照)
|
||||||
|
|
||||||
|
#### 事件 B:接单/指派
|
||||||
|
|
||||||
|
- 更新:`ml_delivery_tasks`
|
||||||
|
- `status: 1 -> 2`
|
||||||
|
- `assigned_at` 写入
|
||||||
|
- 绑定 `driver_id`
|
||||||
|
|
||||||
|
#### 事件 C:取货→配送→送达(或失败)
|
||||||
|
|
||||||
|
- 更新:`ml_delivery_tasks`
|
||||||
|
- `2 -> 3`(写 `picked_at`)
|
||||||
|
- `3 -> 4`
|
||||||
|
- `4 -> 5`(写 `delivered_at`)
|
||||||
|
- 失败:`status = 6` + `failure_reason`
|
||||||
|
|
||||||
|
> 一致性提示(现状):未看到 DB 侧“配送送达 → 自动联动订单状态”的权威触发器/RPC 设计,需在 service/RPC 层承担联动更新职责。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3.1 配送状态 → 订单状态的建议映射(统一口径)
|
||||||
|
|
||||||
|
为避免三方对“配送进展如何反映到订单状态”理解不一致,建议采用以下最小映射(可在 service/RPC 事务中实现):
|
||||||
|
|
||||||
|
| 配送任务 `status` | 建议同步的 `ml_orders.shipping_status` | 建议同步的 `ml_orders.order_status` | 说明 |
|
||||||
|
| ----------------- | -------------------------------------- | ----------------------------------------------------- | ------------------------- |
|
||||||
|
| 1 待接单 | 1 未发货 | 2 待发货(已支付) | 任务生成但未接单 |
|
||||||
|
| 2 已接单 | 2 已发货 | 2 待发货 | 商家/平台已指派,但未取货 |
|
||||||
|
| 3 取货中 | 3 运输中 | 3 待收货 | 骑手已取货,在途 |
|
||||||
|
| 4 配送中 | 3 运输中 | 3 待收货 | 仍在途 |
|
||||||
|
| 5 已送达 | 4 已送达 | 3 待收货(或直接 4 已完成,取决于产品是否需用户确认) | 骑手确认送达 |
|
||||||
|
| 6 配送失败 | 1 未发货(或自定义状态) | 2 待发货(或进入异常分支) | 需人工介入 |
|
||||||
|
|
||||||
|
> **关键决策点**:`status=5` 时,`order_status` 是“待收货”还是直接“已完成”,取决于产品是否需要用户“确认收货”这一步。若不需要,可直接 `4`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3.2 派单依据(任务池可见性)的字段来源
|
||||||
|
|
||||||
|
`ml_delivery_drivers` 与 `ml_delivery_tasks` 中的以下字段是“任务池筛选/派单算法”的数据基础:
|
||||||
|
|
||||||
|
- **服务区域**:`ml_delivery_drivers.service_areas`(JSONB),用于按区域过滤可接单骑手
|
||||||
|
- **实时位置**:`current_lat/current_lng`,用于距离排序/就近派单
|
||||||
|
- **工作状态**:`work_status`(在线/忙碌/离线),用于过滤可用骑手
|
||||||
|
- **任务状态**:`ml_delivery_tasks.status=1`,用于构建“待接单任务池”
|
||||||
|
|
||||||
|
> 权限提示:若任务池需对骑手可见,需配合 RLS 或 RPC 实现“仅可看同区域且状态=1 的任务”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 状态机统一口径(必须统一的单一真相)
|
||||||
|
|
||||||
|
### 4.1 订单并行状态字段
|
||||||
|
|
||||||
|
`ml_orders` 存在三条并行状态线:
|
||||||
|
|
||||||
|
- `order_status`:订单流程
|
||||||
|
- `payment_status`:支付/退款
|
||||||
|
- `shipping_status`:发货/物流
|
||||||
|
|
||||||
|
推荐统一驱动关系:
|
||||||
|
|
||||||
|
- 支付成功驱动 `order_status` 进入“待发货”阶段
|
||||||
|
- 发货/配送接管驱动 `order_status` 进入“待收货”阶段
|
||||||
|
- 签收/确认收货驱动 `order_status` 完成
|
||||||
|
- 退款以 `payment_status` 为主线,同时影响 `order_status` 分支
|
||||||
|
|
||||||
|
### 4.2 `order_status = 5` 的歧义
|
||||||
|
|
||||||
|
文档指出 `5` 在不同脚本存在“已取消/已取货”表述差异。
|
||||||
|
|
||||||
|
- 无自提流程:建议统一为“已取消”
|
||||||
|
- 有自提流程:建议拆出独立状态值(避免取消与自提完成混用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 读模型(各端典型查询视角)
|
||||||
|
|
||||||
|
> 本节描述“应该怎么查”,不涉及具体实现。
|
||||||
|
|
||||||
|
### 5.1 消费者端
|
||||||
|
|
||||||
|
- 商品列表/详情:优先基于商品详情视图(若存在)或 `ml_products`(上架商品)
|
||||||
|
- 我的订单列表:`ml_orders.user_id = 当前用户` + 时间倒序
|
||||||
|
- 订单详情:`ml_orders` + `ml_order_items`
|
||||||
|
- 配送进度:若需展示,则读取 `ml_delivery_tasks`(需权限策略或 RPC 支持)
|
||||||
|
|
||||||
|
### 5.2 商家端
|
||||||
|
|
||||||
|
- 我的商品:`ml_products.merchant_id = 当前商家`
|
||||||
|
- 注意:若 `ml_products` 的 RLS select 仅允许 `status=1`,商家后台查看草稿/下架需额外策略或 RPC
|
||||||
|
- 店铺订单:`ml_orders.merchant_id = 当前商家` + `ml_order_items`
|
||||||
|
- 配送任务看板:按订单关联 `ml_delivery_tasks`
|
||||||
|
|
||||||
|
> **现状风险(RLS)**:`ml_products_select_policy` 仅允许 `status=1`,会导致商家后台直查表时看不到自己的草稿/下架商品。建议扩展 RLS 或为商家后台提供专用 RPC。
|
||||||
|
|
||||||
|
### 5.3 配送端
|
||||||
|
|
||||||
|
- 待接单任务池:`ml_delivery_tasks.status = 1`(通常需按服务区域过滤)
|
||||||
|
- 我的任务:`ml_delivery_tasks.driver_id = 当前配送员`
|
||||||
|
- 任务详情:最小化暴露订单/个人敏感信息(原则层面)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 权限边界(RLS / RPC / 前端守卫的闭环)
|
||||||
|
|
||||||
|
### 6.1 前端工程约束
|
||||||
|
|
||||||
|
- 数据访问唯一入口:必须通过 `services/`
|
||||||
|
- 客户端路由守卫用于快速失败,但最终权限以数据库为准
|
||||||
|
|
||||||
|
### 6.2 admin / analytics 的全局数据访问
|
||||||
|
|
||||||
|
按项目规范与策略文档:
|
||||||
|
|
||||||
|
- 不建议对 admin/analytics 直接开放业务表全局 `SELECT`
|
||||||
|
- 应通过 RPC(`SECURITY DEFINER` + 固定 `search_path` + 入口 `get_current_user_role()` 鉴权)返回最小必要字段/聚合数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 金额字段的归因与缺口(运费/优惠/抽佣)
|
||||||
|
|
||||||
|
| 金额字段 | 数据来源 | 归因说明 | 现状缺口 |
|
||||||
|
| --------------------------- | ----------------------------------------------------- | ------------------------------------------------- | --------------------------------------- |
|
||||||
|
| `ml_orders.shipping_fee` | `ml_system_configs.shipping_fee`(或按距离/重量计算) | 配送费,可配置默认值或按规则计算 | 缺少运费规则表(如按区域/重量/距离) |
|
||||||
|
| `ml_orders.discount_amount` | 优惠券/活动/满减 | 优惠金额,使用券时关联 `ml_user_coupons.order_id` | 缺少活动/满减规则表,复杂优惠需扩表 |
|
||||||
|
| `ml_orders.total_amount` | 商品金额 + 运费 - 优惠 | 订单最终应付 | — |
|
||||||
|
| `platform_commission` | `ml_system_configs.platform_commission`(比例) | 平台抽佣,通常在结算时计算 | 缺少结算单/分账流水表,抽佣未落结算流水 |
|
||||||
|
|
||||||
|
> **结论**:金额归因在“配置 → 落表”层面基本可表达,但缺少结算/对账/复杂优惠的支撑表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 系统配置(system-settings)与“配置驱动流程”的现状说明
|
||||||
|
|
||||||
|
- DB 已存在:`ml_system_configs`,并在参考脚本中初始化了配置项(如 `shipping_fee`、`platform_commission`、`order_auto_confirm_days`)。
|
||||||
|
- 管理端页面现状:`pages/mall/admin/system-settings.uvue` 目前为占位页,仅展示 query 参数,未接入配置读写。
|
||||||
|
|
||||||
|
结论:配置承载表存在,但尚未形成“配置 → 业务流程”的实际驱动闭环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 兼容性与风险
|
||||||
|
|
||||||
|
- **状态机风险**:三条并行状态字段若缺少统一约束,易出现互相矛盾的状态组合。
|
||||||
|
- **一致性风险**:库存扣减/冻结、配送送达与订单联动等关键一致性未在当前权威 SQL 中体现,需在应用层或后续 RPC/触发器补齐。
|
||||||
|
- **权限风险**:配送域 RLS/RPC 若未补齐,会导致配送端无法形成可运转的“接单→更新状态”闭环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚方案
|
||||||
|
|
||||||
|
本文仅新增文档,无业务变更;删除该 markdown 文件即可回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
- 打开本文档,核对引用路径均存在:
|
||||||
|
- `docs/sql/*`、`docs/project_spec/*`
|
||||||
|
- 与现有页面目录结构、角色字段口径(`ak_users.role`)一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/project_spec/AGENT_PROJECT_SPEC.md`
|
||||||
|
- `docs/sql/02_relationships_er.md`
|
||||||
|
- `docs/sql/03_enums_status_dict.md`
|
||||||
|
- `docs/sql/07_business_workflows.md`
|
||||||
|
- `docs/sql/11_roles_and_permissions_strategy.md`
|
||||||
0
docs/sql/00_meta/.keep
Normal file
0
docs/sql/00_meta/.keep
Normal file
9
docs/sql/00_meta/README.md
Normal file
9
docs/sql/00_meta/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# docs/sql/00_meta
|
||||||
|
|
||||||
|
本目录存放:
|
||||||
|
- 规范/策略/说明类文档
|
||||||
|
- 角色与权限策略
|
||||||
|
- RPC 安全模型
|
||||||
|
- 其他元信息
|
||||||
|
|
||||||
|
**注意**:本目录不包含 DDL/RLS/RPC/GRANT 等可执行对象,仅用于策略与说明。
|
||||||
0
docs/sql/10_schema/.keep
Normal file
0
docs/sql/10_schema/.keep
Normal file
8
docs/sql/10_schema/README.md
Normal file
8
docs/sql/10_schema/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# docs/sql/10_schema
|
||||||
|
|
||||||
|
本目录存放:
|
||||||
|
- 表/类型/索引等 DDL
|
||||||
|
- 按域分组(如 `analytics/`、`user/`、`order/` 等)
|
||||||
|
- 文件命名:`<object>_v<version>.sql`
|
||||||
|
|
||||||
|
**禁止**:在此目录混入 RLS、RPC、GRANT 等对象。
|
||||||
19
docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql
Normal file
19
docs/sql/10_schema/user/ak_users_constraints_fix_v1.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- Schema: public.ak_users
|
||||||
|
-- Version: v1
|
||||||
|
-- Purpose: 修复 auth.users -> ak_users 自动同步的写入协调问题
|
||||||
|
-- Change: 放宽 username 和 email 的 NOT NULL 约束,以允许数据库触发器成功插入新用户记录。
|
||||||
|
-- 同时,将 role 的默认值更新为 'customer' 以符合业务逻辑。
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 步骤 1 & 2: 允许 username/email 为空,并更新 role 默认值
|
||||||
|
-- 这样数据库的自动用户同步触发器就不会因为缺少 NOT NULL 的值而失败。
|
||||||
|
-- 前端代码 (ensureUserProfile) 会在用户首次登录时尝试填充这些值。
|
||||||
|
ALTER TABLE public.ak_users
|
||||||
|
ALTER COLUMN username DROP NOT NULL,
|
||||||
|
ALTER COLUMN email DROP NOT NULL,
|
||||||
|
ALTER COLUMN role SET DEFAULT 'customer';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
0
docs/sql/20_rls/.keep
Normal file
0
docs/sql/20_rls/.keep
Normal file
8
docs/sql/20_rls/README.md
Normal file
8
docs/sql/20_rls/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# docs/sql/20_rls
|
||||||
|
|
||||||
|
本目录存放:
|
||||||
|
- RLS 启用与策略(`ENABLE ROW LEVEL SECURITY`、`CREATE/ALTER/DROP POLICY`)
|
||||||
|
- 按域分组(如 `analytics/`、`user/` 等)
|
||||||
|
- 文件命名:`<table>_rls_v<version>.sql`
|
||||||
|
|
||||||
|
**禁止**:在此目录混入 DDL、RPC、GRANT 等对象。
|
||||||
0
docs/sql/30_rpc/.keep
Normal file
0
docs/sql/30_rpc/.keep
Normal file
0
docs/sql/30_rpc/README.md
Normal file
0
docs/sql/30_rpc/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- RPC: rpc_analytics_user_gender_distribution
|
||||||
|
-- Version: v1
|
||||||
|
-- Purpose: 统计指定周期内新增用户的性别分布(用于 Admin/Analytics 图表)
|
||||||
|
-- Security: SECURITY DEFINER + 固定 search_path + 入口角色鉴权
|
||||||
|
-- Depends: public.ak_users, public.get_current_user_role()
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_gender_distribution(
|
||||||
|
p_start_date DATE,
|
||||||
|
p_end_date DATE
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
name TEXT,
|
||||||
|
value BIGINT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF public.get_current_user_role() NOT IN ('admin', 'analytics') THEN
|
||||||
|
RAISE EXCEPTION 'Permission denied: required role admin or analytics';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN gender IS NULL OR TRIM(gender::text) = '' THEN '未知'
|
||||||
|
WHEN LOWER(TRIM(gender::text)) = 'male' THEN '男'
|
||||||
|
WHEN LOWER(TRIM(gender::text)) = 'female' THEN '女'
|
||||||
|
WHEN LOWER(TRIM(gender::text)) = 'other' THEN '未知'
|
||||||
|
ELSE '未知'
|
||||||
|
END AS name,
|
||||||
|
COUNT(*)::BIGINT AS value
|
||||||
|
FROM public.ak_users
|
||||||
|
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY value DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
19
docs/sql/30_rpc/auth/get_current_user_role_v1.sql
Normal file
19
docs/sql/30_rpc/auth/get_current_user_role_v1.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- RPC: get_current_user_role
|
||||||
|
-- Version: v1
|
||||||
|
-- Purpose: 获取当前登录用户的角色(用于 RPC 入口鉴权)
|
||||||
|
-- Security: SECURITY DEFINER + 固定 search_path
|
||||||
|
-- Depends: public.ak_users (auth_id, role)
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_current_user_role()
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT role
|
||||||
|
FROM public.ak_users
|
||||||
|
WHERE auth_id = auth.uid()
|
||||||
|
LIMIT 1;
|
||||||
|
$$;
|
||||||
76
docs/sql/30_rpc/auth/handle_new_user_v2.sql
Normal file
76
docs/sql/30_rpc/auth/handle_new_user_v2.sql
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- Trigger Function: handle_new_user
|
||||||
|
-- Version: v2
|
||||||
|
-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威用户表)并保持 user_roles 兼容写入
|
||||||
|
-- Security: SECURITY DEFINER + 固定 search_path
|
||||||
|
-- Depends:
|
||||||
|
-- - public.ak_users(auth_id,email,username,role)
|
||||||
|
-- - public.user_roles(user_id,role,created_by) (如存在)
|
||||||
|
-- Notes:
|
||||||
|
-- - 角色权威口径为 public.ak_users.role
|
||||||
|
-- - user_roles 为历史/兼容表:存在则写入,不存在则跳过
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_role TEXT := 'customer';
|
||||||
|
user_email TEXT := NEW.email;
|
||||||
|
user_name TEXT;
|
||||||
|
has_user_roles BOOLEAN := FALSE;
|
||||||
|
BEGIN
|
||||||
|
-- 1) 基于邮箱规则分配默认角色(可按需调整)
|
||||||
|
IF user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN
|
||||||
|
user_role := 'teacher';
|
||||||
|
ELSIF user_email ILIKE '%@admin.%' THEN
|
||||||
|
user_role := 'admin';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2) 默认 username:取邮箱 @ 前缀
|
||||||
|
IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
|
||||||
|
user_name := SPLIT_PART(user_email, '@', 1);
|
||||||
|
ELSE
|
||||||
|
user_name := 'user';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3) 写入 ak_users(权威)
|
||||||
|
-- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
|
||||||
|
INSERT INTO public.ak_users (auth_id, email, username, role)
|
||||||
|
VALUES (NEW.id, user_email, user_name, user_role)
|
||||||
|
ON CONFLICT (auth_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
email = COALESCE(EXCLUDED.email, public.ak_users.email),
|
||||||
|
username = COALESCE(EXCLUDED.username, public.ak_users.username),
|
||||||
|
role = COALESCE(public.ak_users.role, EXCLUDED.role),
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- 4) 兼容写入 user_roles(如果表存在)
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema='public'
|
||||||
|
AND table_name='user_roles'
|
||||||
|
) INTO has_user_roles;
|
||||||
|
|
||||||
|
IF has_user_roles THEN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.user_roles (user_id, role, created_by)
|
||||||
|
VALUES (NEW.id, user_role, NEW.id);
|
||||||
|
EXCEPTION WHEN unique_violation THEN
|
||||||
|
-- 忽略重复
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5) 更新 auth.users 元数据(可选保留)
|
||||||
|
UPDATE auth.users
|
||||||
|
SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
81
docs/sql/30_rpc/auth/handle_new_user_v3.sql
Normal file
81
docs/sql/30_rpc/auth/handle_new_user_v3.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- Trigger Function: handle_new_user
|
||||||
|
-- Version: v3
|
||||||
|
-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威)和 public.user_roles(兼容)。
|
||||||
|
-- 此版本修复了向 user_roles 写入时可能因 role 为 NULL 导致的 NOT NULL 约束失败问题。
|
||||||
|
-- Security: SECURITY DEFINER + 固定 search_path
|
||||||
|
-- Depends:
|
||||||
|
-- - public.ak_users(auth_id,email,username,role)
|
||||||
|
-- - public.user_roles(user_id,role)
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_role TEXT;
|
||||||
|
user_email TEXT := NEW.email;
|
||||||
|
user_name TEXT;
|
||||||
|
has_user_roles BOOLEAN := FALSE;
|
||||||
|
BEGIN
|
||||||
|
-- 1) 基于邮箱规则分配默认角色(可按需调整)
|
||||||
|
-- 确保 user_role 总有一个非 NULL 的值
|
||||||
|
user_role := CASE
|
||||||
|
WHEN user_email ILIKE '%@admin.%' THEN 'admin'
|
||||||
|
WHEN user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN 'teacher'
|
||||||
|
ELSE 'consumer' -- 默认角色
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 2) 默认 username:取邮箱 @ 前缀
|
||||||
|
IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
|
||||||
|
user_name := SPLIT_PART(user_email, '@', 1);
|
||||||
|
ELSE
|
||||||
|
user_name := 'user_' || SUBSTRING(NEW.id::text, 1, 8); -- 使用 user_ + uid前8位作为备用名
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3) 写入 ak_users(权威)
|
||||||
|
-- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
|
||||||
|
INSERT INTO public.ak_users (auth_id, email, username, role)
|
||||||
|
VALUES (NEW.id, user_email, user_name, user_role)
|
||||||
|
ON CONFLICT (auth_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
email = COALESCE(EXCLUDED.email, public.ak_users.email),
|
||||||
|
username = COALESCE(EXCLUDED.username, public.ak_users.username),
|
||||||
|
-- 只有当现有 role 为空时才更新,避免覆盖手动设置的 admin 角色
|
||||||
|
role = COALESCE(public.ak_users.role, EXCLUDED.role),
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- 4) 兼容写入 user_roles(如果表存在)
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema='public'
|
||||||
|
AND table_name='user_roles'
|
||||||
|
) INTO has_user_roles;
|
||||||
|
|
||||||
|
IF has_user_roles THEN
|
||||||
|
BEGIN
|
||||||
|
-- 确保插入的 role 不为 NULL,即使上面的逻辑有误
|
||||||
|
INSERT INTO public.user_roles (user_id, role, created_by)
|
||||||
|
VALUES (NEW.id, COALESCE(user_role, 'customer'), NEW.id);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN
|
||||||
|
-- 忽略重复插入的错误
|
||||||
|
NULL;
|
||||||
|
WHEN not_null_violation THEN
|
||||||
|
-- 记录非空约束错误,但不中断整个触发器
|
||||||
|
RAISE NOTICE '[handle_new_user] WARNING: Failed to INSERT into user_roles due to NOT NULL violation. user_id: %, role: %', NEW.id, user_role;
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5) 更新 auth.users 元数据(可选保留)
|
||||||
|
UPDATE auth.users
|
||||||
|
SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
0
docs/sql/40_grants/.keep
Normal file
0
docs/sql/40_grants/.keep
Normal file
0
docs/sql/40_grants/README.md
Normal file
0
docs/sql/40_grants/README.md
Normal file
0
docs/sql/90_archive/.keep
Normal file
0
docs/sql/90_archive/.keep
Normal file
7
docs/sql/90_archive/README.md
Normal file
7
docs/sql/90_archive/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# docs/sql/90_archive
|
||||||
|
|
||||||
|
本目录存放:
|
||||||
|
- 历史/废弃 SQL(只读归档)
|
||||||
|
- 不再作为权威引用口径
|
||||||
|
|
||||||
|
如需保留旧版本 RPC/策略,请迁移到此目录并在新版本中注明替代关系。
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
|
||||||
|
import { getCurrentUser } from '@/utils/store.uts'
|
||||||
import AdminAside from '@/layouts/admin/components/AdminAside.uvue'
|
import AdminAside from '@/layouts/admin/components/AdminAside.uvue'
|
||||||
import AdminSubSider from '@/layouts/admin/components/AdminSubSider.uvue'
|
import AdminSubSider from '@/layouts/admin/components/AdminSubSider.uvue'
|
||||||
import AdminHeader from '@/layouts/admin/components/AdminHeader.uvue'
|
import AdminHeader from '@/layouts/admin/components/AdminHeader.uvue'
|
||||||
@@ -192,7 +194,19 @@ function onNotify(): void {
|
|||||||
// 生命周期
|
// 生命周期
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(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
|
||||||
|
}
|
||||||
|
|
||||||
initNavState()
|
initNavState()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ defineEmits<{
|
|||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:row;
|
flex-direction:row;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
.tag{
|
.tag{
|
||||||
|
flex: 0 0 auto;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
|
|||||||
54
pages/mall/admin/docs/ADMIN_GUARD_STRATEGY.md
Normal file
54
pages/mall/admin/docs/ADMIN_GUARD_STRATEGY.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Admin 模块统一权限守卫策略
|
||||||
|
|
||||||
|
本文档遵循 `@docs/AGENT_PROJECT_SPEC.md` 规范,旨在说明 Admin 模块前端页面的权限守卫实现策略、覆盖范围及待办事项。
|
||||||
|
|
||||||
|
## 1. 统一守卫策略
|
||||||
|
|
||||||
|
根据决策,Admin 模块采用**统一布局守卫**策略。
|
||||||
|
|
||||||
|
- **守卫落点**:`@/layouts/admin/AdminLayout.uvue`
|
||||||
|
- **触发时机**:`onMounted` 生命周期钩子
|
||||||
|
|
||||||
|
所有使用 `<AdminLayout>` 组件包裹的页面,都会自动继承此安全策略,无需在各自页面内部重复编写守卫逻辑。
|
||||||
|
|
||||||
|
## 2. 守卫行为
|
||||||
|
|
||||||
|
守卫逻辑严格分为两步,符合前端“快速失败”和依赖方向原则:
|
||||||
|
|
||||||
|
1. **登录检查(无 IO)**:
|
||||||
|
- 调用 `services/analytics/authGuard.uts` 中的 `ensureAnalyticsLogin()`。
|
||||||
|
- **行为**:如果用户未登录(无有效 session),则中断页面加载,并自动跳转到登录页(`/pages/user/login`),同时附带 `redirect` 参数,以便登录后能返回原页面。
|
||||||
|
|
||||||
|
2. **角色检查(依赖本地 Profile)**:
|
||||||
|
- 调用 `utils/store.uts` 中的 `await getCurrentUser()` 获取当前用户的 `profile`,该 `profile` 中包含了从 `ak_users` 同步的 `role` 字段。
|
||||||
|
- **行为**:检查 `role` 是否为 `'admin'` 或 `'analytics'` 之一。如果不是,则:
|
||||||
|
- `uni.showToast({ title: '权限不足' })`
|
||||||
|
- 800ms 后 `uni.switchTab({ url: '/pages/mall/consumer/index' })`(按决策 B 跳转到消费者首页)。
|
||||||
|
|
||||||
|
只有当登录和角色检查都通过后,`AdminLayout` 才会继续执行其内部的导航和组件渲染逻辑。
|
||||||
|
|
||||||
|
## 3. 覆盖范围
|
||||||
|
|
||||||
|
### 已确认被统一守卫覆盖的页面:
|
||||||
|
|
||||||
|
- `pages/mall/admin/index_new.uvue` (通过其使用的 `AdminLayout`)
|
||||||
|
- `pages/mall/admin/product/product-statistics/index.uvue`
|
||||||
|
- `pages/mall/admin/user/Statistic.uvue`
|
||||||
|
- `pages/mall/admin/order/order-statistics/index.uvue` (本次已改造)
|
||||||
|
- 其他所有在模板中使用了 `<AdminLayout>` 的页面。
|
||||||
|
|
||||||
|
### **例外与风险**:未被统一守卫覆盖的 Admin 页面
|
||||||
|
|
||||||
|
以下在 `pages.json` 中注册的 `admin` 路由,由于是独立的“裸”页面,**不会**被 `AdminLayout` 的统一守卫保护,存在安全风险:
|
||||||
|
|
||||||
|
- `pages/mall/admin/index`
|
||||||
|
- `pages/mall/admin/user-detail`
|
||||||
|
- `pages/mall/admin/merchant-detail`
|
||||||
|
- `pages/mall/admin/system-monitor`
|
||||||
|
|
||||||
|
## 4. 待办事项
|
||||||
|
|
||||||
|
为实现 Admin 模块权限的全覆盖,建议后续处理上述例外页面:
|
||||||
|
|
||||||
|
- **方案 1(推荐)**:将这些独立的页面改造为使用 `<AdminLayout>` 包裹,使其自动纳入统一守卫范围。
|
||||||
|
- **方案 2(临时)**:在这些页面的 `onLoad` 钩子中,手动加入与 `AdminLayout` 中相同的守卫逻辑代码。
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 2026-02-05__admin__user-management-db-integration.md
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
为管理后台用户管理模块(`pages/mall/admin/user/user-management/index.uvue`)接入数据库,实现用户列表分页查询、筛选、用户详情展示,并支持会员信息(是否会员 + 方案名 + 到期时间)与余额(固定 0)的展示。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
- 原 admin 用户管理页面为占位页,无真实数据接入
|
||||||
|
- 需要按照项目规范(`AGENT_PROJECT_SPEC.md`)通过 `services/` 层访问数据,admin 全局数据走 RPC
|
||||||
|
- 用户管理需展示会员信息(基于 `ml_user_subscriptions`)与余额(暂固定 0)
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- **数据库层**:新增 RPC(暂存于 `test/`,待验证后入库到 `docs/sql/30_rpc/admin/`)
|
||||||
|
- **服务层**:新增 `services/admin/AdminUserService.uts`
|
||||||
|
- **页面层**:`pages/mall/admin/user/user-management/index.uvue` 从占位页升级为完整功能页
|
||||||
|
- **工具层**:`utils/supabaseService.uts` 新增 `export { supa }` 供 services 统一使用
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
- `pages/mall/admin/user/test/rpc_admin_user_list_v1.sql` - 用户列表 RPC(分页+筛选)
|
||||||
|
- `pages/mall/admin/user/test/rpc_admin_user_detail_v1.sql` - 用户详情 RPC
|
||||||
|
- `services/admin/AdminUserService.uts` - Admin 用户管理服务封装
|
||||||
|
- `pages/mall/admin/docs/ops/2026-02-05__admin__user-management-db-integration.md` - 本操作文档
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `pages/mall/admin/user/user-management/index.uvue` - 从占位页升级为完整功能页
|
||||||
|
- `utils/supabaseService.uts` - 新增 `export { supa }` 供 services 层统一使用
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 兼容性与风险
|
||||||
|
|
||||||
|
### 兼容性
|
||||||
|
- RPC 依赖现有表结构:`ak_users`、`ml_user_profiles`、`ml_user_subscriptions`、`ml_subscription_plans`、`ml_user_addresses`
|
||||||
|
- 会员关联口径:`ml_user_subscriptions.user_id = ak_users.auth_id`(即 `auth.users.id`)
|
||||||
|
- 余额字段固定返回 0,不影响现有业务
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
- RPC 暂存于 `test/` 目录,未入库到 `docs/sql/`,需验证后再迁移
|
||||||
|
- 新增 `export { supa }` 可能影响其他模块的导入行为(但为统一出口,风险较低)
|
||||||
|
- 页面功能较复杂,需充分测试分页、筛选、弹窗等交互
|
||||||
|
|
||||||
|
## 回滚方案
|
||||||
|
|
||||||
|
1. **回滚页面**:恢复 `pages/mall/admin/user/user-management/index.uvue` 为原占位页内容
|
||||||
|
2. **回滚服务**:删除 `services/admin/AdminUserService.uts`
|
||||||
|
3. **回滚工具**:移除 `utils/supabaseService.uts` 中的 `export { supa }`
|
||||||
|
4. **回滚 RPC**:删除 `pages/mall/admin/user/test/` 下的两个 RPC 文件
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
1. 访问管理后台 → 用户管理 → 用户列表
|
||||||
|
2. 验证列表数据加载正常,显示用户基本信息、会员信息、余额
|
||||||
|
3. 测试筛选功能:搜索、角色、状态、会员筛选
|
||||||
|
4. 测试分页功能:上一页/下一页
|
||||||
|
5. 点击"查看"按钮,验证用户详情弹窗正常显示
|
||||||
|
6. 验证详情页中的会员信息(方案名、到期时间)和地址信息
|
||||||
|
|
||||||
|
### 技术验证
|
||||||
|
1. 检查 RPC 调用是否正常返回数据
|
||||||
|
2. 检查服务层数据转换是否正确
|
||||||
|
3. 检查页面渲染是否正常,无报错
|
||||||
|
4. 检查网络请求是否通过 services 层,未直接访问 supabase client
|
||||||
|
|
||||||
|
### SQL 验证
|
||||||
|
```sql
|
||||||
|
-- 验证 RPC 是否创建成功
|
||||||
|
SELECT proname, prosrc FROM pg_proc WHERE proname LIKE 'rpc_admin_user_%';
|
||||||
|
|
||||||
|
-- 测试用户列表 RPC
|
||||||
|
SELECT * FROM rpc_admin_user_list(1, 20, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
-- 测试用户详情 RPC(替换为真实用户ID)
|
||||||
|
SELECT * FROM rpc_admin_user_detail('your-user-id-here');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/project_spec/AGENT_PROJECT_SPEC.md` - 项目规范文档
|
||||||
|
- `docs/sql/30_rpc/auth/get_current_user_role_v1.sql` - RPC 鉴权入口
|
||||||
|
- `mall_sql/schemas/complete_mall_database.sql` - 数据库表结构
|
||||||
|
- `doc_mall/create_mall_subscription_tables.sql` - 订阅表结构
|
||||||
|
- `pages/mall/admin/user/docs/USER_STATISTICS_DB.md` - 用户统计相关文档
|
||||||
|
|
||||||
|
## 技术实现要点
|
||||||
|
|
||||||
|
### RPC 设计
|
||||||
|
- 使用 `SECURITY DEFINER` + `SET search_path = public`
|
||||||
|
- 入口鉴权:`get_current_user_role() IN ('admin', 'analytics')`
|
||||||
|
- 支持分页、搜索、多维度筛选
|
||||||
|
- 会员信息通过 `LATERAL JOIN` 获取最新有效订阅
|
||||||
|
|
||||||
|
### 服务层设计
|
||||||
|
- 封装 RPC 调用,提供类型安全的接口
|
||||||
|
- 统一错误处理和日志记录
|
||||||
|
- 数据转换:UTSJSONObject → TypeScript 类型
|
||||||
|
|
||||||
|
### 页面设计
|
||||||
|
- 响应式布局,支持移动端
|
||||||
|
- 完整的筛选和分页功能
|
||||||
|
- 用户详情弹窗展示完整信息
|
||||||
|
- 会员信息按方案 C 展示:是否会员 + 方案名 + 到期时间
|
||||||
|
|
||||||
|
## 后续计划
|
||||||
|
|
||||||
|
1. **RPC 入库**:验证通过后,将 RPC 迁移到 `docs/sql/30_rpc/admin/`
|
||||||
|
2. **功能扩展**:根据业务需求,可能需要添加用户编辑、状态变更等功能
|
||||||
|
3. **性能优化**:考虑添加缓存、索引优化等
|
||||||
|
4. **余额功能**:当钱包/余额表实现后,接入真实余额数据
|
||||||
|
|
||||||
|
## 部署注意事项
|
||||||
|
|
||||||
|
1. 确保数据库表结构已部署(`ml_user_profiles`、`ml_user_subscriptions` 等)
|
||||||
|
2. 确保 `get_current_user_role()` RPC 已部署
|
||||||
|
3. 部署前建议在测试环境验证 RPC 功能正常
|
||||||
|
4. 页面部署后检查控制台是否有错误信息
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="order-statistic-page">
|
<AdminLayout :currentPage="currentPage">
|
||||||
|
<view class="order-statistic-page">
|
||||||
<!-- 时间选择卡片 -->
|
<!-- 时间选择卡片 -->
|
||||||
<view class="filter-card">
|
<view class="filter-card">
|
||||||
<view class="filter-item">
|
<view class="filter-item">
|
||||||
@@ -115,12 +116,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
|
||||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||||
|
|
||||||
|
const currentPage = ref<string>('order_statistic')
|
||||||
|
|
||||||
const title = ref<string>('订单统计')
|
const title = ref<string>('订单统计')
|
||||||
const trendOption = ref<any>({})
|
const trendOption = ref<any>({})
|
||||||
const sourceOption = ref<any>({})
|
const sourceOption = ref<any>({})
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="filter-item">
|
<view class="filter-item">
|
||||||
<text class="filter-label">选择时间:</text>
|
<text class="filter-label">选择月份:</text>
|
||||||
<view class="date-picker-box">
|
<AnalyticsDateRangePicker
|
||||||
<text class="date-icon">📅</text>
|
:initialStartDate="startDate"
|
||||||
<text class="date-text">2026/01/04 - 2026/02/02</text>
|
:initialEndDate="endDate"
|
||||||
</view>
|
@apply="onDateRangeApply"
|
||||||
|
@clear="onDateRangeClear"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="filter-btns">
|
<view class="filter-btns">
|
||||||
@@ -69,45 +71,203 @@
|
|||||||
<AnalyticsUserMapTable />
|
<AnalyticsUserMapTable />
|
||||||
</view>
|
</view>
|
||||||
<view class="gender-col">
|
<view class="gender-col">
|
||||||
<AnalyticsUserGenderSection />
|
<AnalyticsUserGenderSection :startDate="startDate" :endDate="endDate" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
||||||
import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.uvue'
|
import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.uvue'
|
||||||
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.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 = [
|
type KpiCard = {
|
||||||
{ title: '累计用户', value: '80834', percent: '0.84%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
title: string
|
||||||
{ title: '访客数', value: '1138', percent: '1.04%', trend: 'down', icon: '👤', bg: '#e0f2fe' },
|
value: string
|
||||||
{ title: '浏览量', value: '9519', percent: '2.34%', trend: 'down', icon: '👁️', bg: '#dcfce7' },
|
percent: string
|
||||||
{ title: '新增用户数', value: '680', percent: '4.36%', trend: 'down', icon: '👤', bg: '#ffedd5' },
|
trend: string
|
||||||
{ title: '成交用户数', value: '132', percent: '11.86%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
icon: string
|
||||||
{ title: '付费会员数', value: '79', percent: '7.05%', trend: 'down', icon: '💎', bg: '#f3e8ff' }
|
bg: string
|
||||||
]
|
}
|
||||||
|
|
||||||
const chartData = {
|
type LineSeries = {
|
||||||
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'],
|
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: [
|
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: '#1890ff', data: [] },
|
||||||
{ 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: '#52c41a', data: [] }
|
||||||
{ 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] }
|
|
||||||
]
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
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() {
|
function onSearch() {
|
||||||
uni.showToast({ title: '搜索中...' })
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onExport() {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -222,51 +382,88 @@ function onExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kpi-row {
|
.kpi-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
flex-wrap: wrap;
|
gap: 16px;
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 40px;
|
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 {
|
.kpi-card {
|
||||||
flex: 1;
|
min-width: 0;
|
||||||
min-width: 200px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-icon-box {
|
.kpi-icon-box {
|
||||||
width: 44px;
|
width: 32px;
|
||||||
height: 44px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-icon { font-size: 20px; }
|
.kpi-icon { font-size: 14px; }
|
||||||
|
|
||||||
.kpi-content {
|
.kpi-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-label { font-size: 14px; color: #8c8c8c; }
|
.kpi-label {
|
||||||
.kpi-value { font-size: 24px; font-weight: 500; color: #262626; }
|
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 {
|
.kpi-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-size: 12px;
|
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.up { color: #ff4d4f; }
|
||||||
.meta-value.down { color: #52c41a; }
|
.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
@@ -1013,8 +1013,6 @@ function goToFeedback() {
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #8c929b;
|
color: #8c929b;
|
||||||
}
|
}
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-legend {
|
.trend-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
import { IS_TEST_MODE } from '@/ak/config.uts'
|
||||||
import { getCurrentUser, logout } from '@/utils/store.uts'
|
import { getCurrentUser, logout } from '@/utils/store.uts'
|
||||||
|
import { ensureUserProfile } from '@/utils/sapi.uts'
|
||||||
|
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
'--bg': '#f5f6f8',
|
'--bg': '#f5f6f8',
|
||||||
@@ -256,8 +257,8 @@ const getCode = async () => {
|
|||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!validateAccount()) return
|
if (!validateAccount()) return
|
||||||
|
|
||||||
// 特殊账号处理:admin/admin 直接跳转
|
// 特殊账号处理:仅在测试模式允许本地绕过登录(不推荐生产使用)
|
||||||
if (account.value === 'admin' && password.value === 'admin') {
|
if (IS_TEST_MODE && account.value === 'admin' && password.value === 'admin') {
|
||||||
setIsLoggedIn(true)
|
setIsLoggedIn(true)
|
||||||
const adminProfile = {
|
const adminProfile = {
|
||||||
id: 'admin',
|
id: 'admin',
|
||||||
@@ -277,7 +278,7 @@ const handleLogin = async () => {
|
|||||||
} as UserProfile
|
} as UserProfile
|
||||||
setUserProfile(adminProfile)
|
setUserProfile(adminProfile)
|
||||||
|
|
||||||
uni.showToast({ title: '管理员登录成功', icon: 'success' })
|
uni.showToast({ title: '管理员登录成功(测试模式)', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -328,12 +329,26 @@ const handleLogin = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试获取/补全用户资料,但失败时不再阻塞登录
|
// 登录成功后强制同步用户资料到 ak_users(确保 auth_id 与 role 落表)
|
||||||
try {
|
try {
|
||||||
const profile = await getCurrentUser()
|
const sessionInfo = supa.getSession()
|
||||||
console.log('current user profile:', profile)
|
if (sessionInfo?.user != null) {
|
||||||
|
const syncResult = await ensureUserProfile(sessionInfo.user)
|
||||||
|
console.log('ensureUserProfile sync result:', syncResult)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取用户信息失败(忽略,不阻塞登录):', e)
|
console.error('同步用户资料到 ak_users 失败(不阻塞登录):', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功后按 redirect 参数跳转(守卫放在需要权限的页面入口)
|
||||||
|
const pages = getCurrentPages() as any[]
|
||||||
|
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||||
|
const opts = currentPage?.options as any
|
||||||
|
const redirect = opts?.redirect as string | null
|
||||||
|
if (redirect != null && redirect.length > 0) {
|
||||||
|
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
||||||
|
} else {
|
||||||
|
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式保存用户ID到本地存储,确保页面刷新或重启后 SupabaseService 能恢复身份
|
// 显式保存用户ID到本地存储,确保页面刷新或重启后 SupabaseService 能恢复身份
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
236
services/admin/AdminUserService.uts
Normal file
236
services/admin/AdminUserService.uts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { supa } from '@/utils/supabaseService.uts'
|
||||||
|
import type { AkReqResponse } from '@/uni_modules/ak-req/interface.uts'
|
||||||
|
|
||||||
|
// Admin 用户管理相关类型定义
|
||||||
|
export type AdminUserItem = {
|
||||||
|
id: string
|
||||||
|
auth_id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
profile_status?: number
|
||||||
|
real_name?: string
|
||||||
|
credit_score?: number
|
||||||
|
verification_status?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
balance: number // 固定 0
|
||||||
|
is_member: boolean
|
||||||
|
member_plan_name?: string
|
||||||
|
member_end_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserDetail = {
|
||||||
|
id: string
|
||||||
|
auth_id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
profile_status?: number
|
||||||
|
real_name?: string
|
||||||
|
credit_score?: number
|
||||||
|
verification_status?: number
|
||||||
|
preferences?: UTSJSONObject
|
||||||
|
emergency_contact?: string
|
||||||
|
service_areas?: UTSJSONObject
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
balance: number // 固定 0
|
||||||
|
is_member: boolean
|
||||||
|
member_info?: {
|
||||||
|
plan_name: string
|
||||||
|
plan_code: string
|
||||||
|
billing_period: string
|
||||||
|
price: number
|
||||||
|
features: UTSJSONObject
|
||||||
|
status: string
|
||||||
|
start_date: string
|
||||||
|
end_date?: string
|
||||||
|
next_billing_date?: string
|
||||||
|
auto_renew: boolean
|
||||||
|
}
|
||||||
|
addresses?: Array<{
|
||||||
|
id: string
|
||||||
|
receiver_name: string
|
||||||
|
receiver_phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
address_detail: string
|
||||||
|
postal_code?: string
|
||||||
|
is_default: boolean
|
||||||
|
label?: string
|
||||||
|
latitude?: number
|
||||||
|
longitude?: number
|
||||||
|
delivery_instructions?: string
|
||||||
|
business_hours?: string
|
||||||
|
status: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserListParams = {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
role?: string
|
||||||
|
status?: number
|
||||||
|
is_member?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserListResponse = {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
has_more: boolean
|
||||||
|
items: Array<AdminUserItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin 用户管理服务
|
||||||
|
* 封装对 admin 用户管理相关 RPC 的调用
|
||||||
|
* 符合项目规范:页面/组件不得直接访问 supabase client,必须通过 services
|
||||||
|
*/
|
||||||
|
export class AdminUserService {
|
||||||
|
/**
|
||||||
|
* 获取用户列表(分页+筛选)
|
||||||
|
* @param params 查询参数
|
||||||
|
* @returns Promise<AdminUserListResponse>
|
||||||
|
*/
|
||||||
|
static async getUserList(params: AdminUserListParams = {}): Promise<AdminUserListResponse> {
|
||||||
|
try {
|
||||||
|
const rpcParams = {
|
||||||
|
p_page: params.page ?? 1,
|
||||||
|
p_limit: params.limit ?? 20,
|
||||||
|
p_search: params.search ?? null,
|
||||||
|
p_role: params.role ?? null,
|
||||||
|
p_status: params.status ?? null,
|
||||||
|
p_is_member: params.is_member ?? null
|
||||||
|
} as UTSJSONObject
|
||||||
|
|
||||||
|
const response = await supa.rpc('rpc_admin_user_list', rpcParams)
|
||||||
|
|
||||||
|
if (response.error != null) {
|
||||||
|
throw new Error(`RPC 调用失败: ${response.error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data as UTSJSONObject
|
||||||
|
return {
|
||||||
|
total: data.getNumber('total') ?? 0,
|
||||||
|
page: data.getNumber('page') ?? 1,
|
||||||
|
limit: data.getNumber('limit') ?? 20,
|
||||||
|
has_more: data.getBoolean('has_more') ?? false,
|
||||||
|
items: (data.getArray('items') as Array<UTSJSONObject>)?.map(item => ({
|
||||||
|
id: item.getString('id') ?? '',
|
||||||
|
auth_id: item.getString('auth_id') ?? '',
|
||||||
|
username: item.getString('username') ?? '',
|
||||||
|
email: item.getString('email') ?? '',
|
||||||
|
role: item.getString('role') ?? '',
|
||||||
|
profile_status: item.getNumber('profile_status'),
|
||||||
|
real_name: item.getString('real_name'),
|
||||||
|
credit_score: item.getNumber('credit_score'),
|
||||||
|
verification_status: item.getNumber('verification_status'),
|
||||||
|
created_at: item.getString('created_at') ?? '',
|
||||||
|
updated_at: item.getString('updated_at') ?? '',
|
||||||
|
balance: item.getNumber('balance') ?? 0,
|
||||||
|
is_member: item.getBoolean('is_member') ?? false,
|
||||||
|
member_plan_name: item.getString('member_plan_name'),
|
||||||
|
member_end_date: item.getString('member_end_date')
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminUserService] getUserList error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
* @param userId 用户ID(ak_users.id)
|
||||||
|
* @returns Promise<AdminUserDetail | null>
|
||||||
|
*/
|
||||||
|
static async getUserDetail(userId: string): Promise<AdminUserDetail | null> {
|
||||||
|
try {
|
||||||
|
const response = await supa.rpc('rpc_admin_user_detail', { p_user_id: userId } as UTSJSONObject)
|
||||||
|
|
||||||
|
if (response.error != null) {
|
||||||
|
throw new Error(`RPC 调用失败: ${response.error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data as UTSJSONObject
|
||||||
|
if (data == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析会员信息
|
||||||
|
let memberInfo: any = null
|
||||||
|
const memberInfoObj = data.getJSONObject('member_info')
|
||||||
|
if (memberInfoObj != null) {
|
||||||
|
memberInfo = {
|
||||||
|
plan_name: memberInfoObj.getString('plan_name') ?? '',
|
||||||
|
plan_code: memberInfoObj.getString('plan_code') ?? '',
|
||||||
|
billing_period: memberInfoObj.getString('billing_period') ?? '',
|
||||||
|
price: memberInfoObj.getNumber('price') ?? 0,
|
||||||
|
features: memberInfoObj.getJSONObject('features') ?? {},
|
||||||
|
status: memberInfoObj.getString('status') ?? '',
|
||||||
|
start_date: memberInfoObj.getString('start_date') ?? '',
|
||||||
|
end_date: memberInfoObj.getString('end_date'),
|
||||||
|
next_billing_date: memberInfoObj.getString('next_billing_date'),
|
||||||
|
auto_renew: memberInfoObj.getBoolean('auto_renew') ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析地址列表
|
||||||
|
let addresses: any[] = []
|
||||||
|
const addressesArray = data.getArray('addresses') as Array<UTSJSONObject>
|
||||||
|
if (addressesArray != null) {
|
||||||
|
addresses = addressesArray.map(addr => ({
|
||||||
|
id: addr.getString('id') ?? '',
|
||||||
|
receiver_name: addr.getString('receiver_name') ?? '',
|
||||||
|
receiver_phone: addr.getString('receiver_phone') ?? '',
|
||||||
|
province: addr.getString('province') ?? '',
|
||||||
|
city: addr.getString('city') ?? '',
|
||||||
|
district: addr.getString('district') ?? '',
|
||||||
|
address_detail: addr.getString('address_detail') ?? '',
|
||||||
|
postal_code: addr.getString('postal_code'),
|
||||||
|
is_default: addr.getBoolean('is_default') ?? false,
|
||||||
|
label: addr.getString('label'),
|
||||||
|
latitude: addr.getNumber('latitude'),
|
||||||
|
longitude: addr.getNumber('longitude'),
|
||||||
|
delivery_instructions: addr.getString('delivery_instructions'),
|
||||||
|
business_hours: addr.getString('business_hours'),
|
||||||
|
status: addr.getNumber('status') ?? 1,
|
||||||
|
created_at: addr.getString('created_at') ?? '',
|
||||||
|
updated_at: addr.getString('updated_at') ?? ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.getString('id') ?? '',
|
||||||
|
auth_id: data.getString('auth_id') ?? '',
|
||||||
|
username: data.getString('username') ?? '',
|
||||||
|
email: data.getString('email') ?? '',
|
||||||
|
role: data.getString('role') ?? '',
|
||||||
|
profile_status: data.getNumber('profile_status'),
|
||||||
|
real_name: data.getString('real_name'),
|
||||||
|
credit_score: data.getNumber('credit_score'),
|
||||||
|
verification_status: data.getNumber('verification_status'),
|
||||||
|
preferences: data.getJSONObject('preferences'),
|
||||||
|
emergency_contact: data.getString('emergency_contact'),
|
||||||
|
service_areas: data.getJSONObject('service_areas'),
|
||||||
|
created_at: data.getString('created_at') ?? '',
|
||||||
|
updated_at: data.getString('updated_at') ?? '',
|
||||||
|
balance: data.getNumber('balance') ?? 0,
|
||||||
|
is_member: data.getBoolean('is_member') ?? false,
|
||||||
|
member_info: memberInfo,
|
||||||
|
addresses: addresses
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminUserService] getUserDetail error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserService
|
||||||
@@ -194,6 +194,27 @@ export class AkReq {
|
|||||||
resolve(result);
|
resolve(result);
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
|
// 失败诊断:输出 URL / 关键 headers / errCode / errMsg,便于定位“网络不可达 vs 401/403”等问题
|
||||||
|
try {
|
||||||
|
let apikeyMasked: string | null = null;
|
||||||
|
try {
|
||||||
|
if (headers != null && typeof (headers as any).getString === 'function') {
|
||||||
|
const k = (headers as any).getString('apikey') as string | null;
|
||||||
|
if (k != null && k.length > 8) apikeyMasked = k.substring(0, 6) + '...' + k.substring(k.length - 4);
|
||||||
|
else apikeyMasked = k;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
console.error('[AkReq][fail]', {
|
||||||
|
url: options.url,
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
timeout,
|
||||||
|
errCode: (err as any).errCode,
|
||||||
|
errMsg: (err as any).errMsg,
|
||||||
|
apikey: apikeyMasked,
|
||||||
|
hasAuth: (AkReq.getToken() != null && AkReq.getToken() !== '')
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const result = AkReq.createResponse<any>(
|
const result = AkReq.createResponse<any>(
|
||||||
err.errCode,
|
err.errCode,
|
||||||
err.data ?? {},
|
err.data ?? {},
|
||||||
|
|||||||
@@ -56,10 +56,12 @@ export async function ensureUserProfile(sessionUser: UTSJSONObject): Promise<Use
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户不存在,创建新用户资料
|
// 用户不存在,创建新用户资料
|
||||||
|
// 权威字段映射:ak_users.auth_id = auth.uid(),业务主键 id 由数据库自动生成
|
||||||
const newUserData = new UTSJSONObject()
|
const newUserData = new UTSJSONObject()
|
||||||
newUserData.set('id', userId)
|
newUserData.set('auth_id', userId)
|
||||||
newUserData.set('email', email)
|
newUserData.set('email', email)
|
||||||
newUserData.set('username', email.split('@')[0] ?? 'user') // 默认用户名为邮箱前缀
|
newUserData.set('username', email.split('@')[0] ?? 'user') // 默认用户名为邮箱前缀
|
||||||
|
newUserData.set('role', 'consumer')
|
||||||
|
|
||||||
const insertRes = await supabase.from('ak_users')
|
const insertRes = await supabase.from('ak_users')
|
||||||
.insert(newUserData)
|
.insert(newUserData)
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export async function getCurrentUser() : Promise<UserProfile | null> {
|
|||||||
state.userProfile = { username: '', email: '' }
|
state.userProfile = { username: '', email: '' }
|
||||||
state.isLoggedIn = false // 未登录
|
state.isLoggedIn = false // 未登录
|
||||||
return null
|
return null
|
||||||
} // 查询 ak_users 表补全 profile
|
} // 查询 ak_users 表补全 profile(权威关联字段:auth_id = auth.uid())
|
||||||
const res = await supa.from('ak_users').select('*', {}).eq('id', userId).execute()
|
const res = await supa.from('ak_users').select('*', {}).eq('auth_id', userId).execute()
|
||||||
console.log(res)
|
console.log(res)
|
||||||
if (res.status >= 200 && res.status < 300 && (res.data != null)) {
|
if (res.status >= 200 && res.status < 300 && (res.data != null)) {
|
||||||
let user : UTSJSONObject | null = null;
|
let user : UTSJSONObject | null = null;
|
||||||
@@ -182,13 +182,11 @@ export function getUserStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getUserRole() : string | null {
|
getUserRole() : string | null {
|
||||||
// Default role logic - can be enhanced based on your needs
|
const profile = state.userProfile
|
||||||
const sessionInfo = supa.getSession()
|
if (profile != null && profile.role != null) {
|
||||||
if (sessionInfo.user == null) return null
|
return profile.role
|
||||||
|
}
|
||||||
// You can add role detection logic here
|
return null
|
||||||
// For now, return a default role
|
|
||||||
return 'teacher' // or determine from user profile/database
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getProfile() : UserProfile | null {
|
getProfile() : UserProfile | null {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||||
|
|
||||||
|
// 导出 supa 实例,供 services 层统一使用
|
||||||
|
export { supa }
|
||||||
|
|
||||||
// 使用单例 Supabase 客户端
|
// 使用单例 Supabase 客户端
|
||||||
// const supa = createClient(SUPA_URL, SUPA_KEY)
|
// const supa = createClient(SUPA_URL, SUPA_KEY)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user