Merge pull request 'comclib-analytics' (#18) from comclib-analytics into main
Reviewed-on: http://hfkj.git.meitizs.com/mall/mall/pulls/18
This commit is contained in:
16
README.md
16
README.md
@@ -26,9 +26,25 @@ pages/
|
||||
nfc/ # NFC相关
|
||||
components/
|
||||
utils/
|
||||
services/
|
||||
types/
|
||||
uni_modules/
|
||||
docs/ # 项目规范与数据库策略(含 docs/sql)
|
||||
mall_sql/ # 数据库迁移/部署脚本与结构化SQL仓库
|
||||
ak/
|
||||
```
|
||||
|
||||
## 📚 文档与数据库目录
|
||||
|
||||
- **`docs/`**
|
||||
- 项目规范与研发约定集合。
|
||||
- **`docs/sql/`**:数据库“权威口径”文档与结构化 SQL 归档(策略、RLS、RPC、授权等)。
|
||||
- Agent 执行规范见:`docs/AGENT_PROJECT_SPEC.md`
|
||||
|
||||
- **`mall_sql/`**
|
||||
- 可执行的数据库脚本与迁移仓库(migrations/schemas/scripts 等)。
|
||||
- 用于数据库部署/迁移/结构化管理(与 `docs/sql` 的“权威口径/评审归档”配合使用)。
|
||||
|
||||
## 🚀 开发说明
|
||||
|
||||
- 使用 HBuilderX 或 uni-app CLI 运行与编译
|
||||
|
||||
@@ -154,10 +154,11 @@ export default {
|
||||
data: x,
|
||||
axisTick: { alignWithLabel: true },
|
||||
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.12)' } },
|
||||
axisLabel: {
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
rotate: x.length > 12 ? 45 : 0,
|
||||
interval: 0
|
||||
// 数据量大时不要强制全部展示,否则会全部重叠
|
||||
interval: x.length > 60 ? 'auto' : 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
|
||||
@@ -4,15 +4,32 @@
|
||||
<!-- 侧边栏菜单 -->
|
||||
<view class="sidebar-menu" :class="{ active: showMenu, 'always-visible': isWideScreen }" @click.stop>
|
||||
<view class="sidebar-content">
|
||||
<view
|
||||
v-for="item in menuItems"
|
||||
<view
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
class="menu-item"
|
||||
:class="{ active: currentPath === item.path }"
|
||||
@click="navigateToPage(item.path)"
|
||||
>
|
||||
<text class="menu-icon">{{ item.icon }}</text>
|
||||
<text class="menu-text">{{ item.title }}</text>
|
||||
<view
|
||||
class="menu-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="onParentClick(item)"
|
||||
>
|
||||
<text class="menu-icon">{{ item.icon }}</text>
|
||||
<text class="menu-text">{{ item.title }}</text>
|
||||
<text v-if="item.children && item.children.length > 0" class="menu-caret">{{ isExpanded(item.path) ? '▾' : '▸' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="item.children && item.children.length > 0 && isExpanded(item.path)" class="submenu">
|
||||
<view
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
class="menu-item menu-item-child"
|
||||
:class="{ active: isActive(child.path), disabled: isCustomReportChildDisabled(item, child) }"
|
||||
@click="onChildClick(item, child)"
|
||||
>
|
||||
<text class="menu-icon">{{ child.icon }}</text>
|
||||
<text class="menu-text">{{ child.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -22,11 +39,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||
import { listCustomReports } from '@/services/analytics/customReportService.uts'
|
||||
|
||||
// 菜单项类型
|
||||
type MenuItem = {
|
||||
path: string
|
||||
title: string
|
||||
icon: string
|
||||
children?: Array<MenuItem>
|
||||
}
|
||||
|
||||
// 菜单配置
|
||||
@@ -39,10 +60,16 @@ const MENU_ITEMS: Array<MenuItem> = [
|
||||
{ path: '/pages/mall/analytics/delivery-analysis', title: '配送效率分析', icon: '🚚' },
|
||||
{ path: '/pages/mall/analytics/coupon-analysis', title: '优惠券效果分析', icon: '🎫' },
|
||||
{ path: '/pages/mall/analytics/market-trends', title: '市场趋势', icon: '📈' },
|
||||
{ path: '/pages/mall/analytics/custom-report', title: '自定义报表', icon: '📋' },
|
||||
{ path: '/pages/mall/analytics/report-detail', title: '报表详情', icon: '📄' },
|
||||
{ path: '/pages/mall/analytics/data-detail', title: '数据分析详情', icon: '🔍' },
|
||||
{ path: '/pages/mall/analytics/insight-detail', title: '数据洞察详情', icon: '💡' }
|
||||
{
|
||||
path: '/pages/mall/analytics/custom-report',
|
||||
title: '自定义报表',
|
||||
icon: '📋',
|
||||
children: [
|
||||
{ path: '/pages/mall/analytics/report-detail', title: '报表详情', icon: '📄' },
|
||||
{ path: '/pages/mall/analytics/data-detail', title: '数据分析详情', icon: '🔍' },
|
||||
{ path: '/pages/mall/analytics/insight-detail', title: '数据洞察详情', icon: '💡' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default {
|
||||
@@ -64,7 +91,9 @@ export default {
|
||||
showMenu: false,
|
||||
menuItems: MENU_ITEMS,
|
||||
isWideScreen: false,
|
||||
screenWidth: 0
|
||||
screenWidth: 0,
|
||||
expanded: {} as any,
|
||||
hasAnyCustomReport: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -81,16 +110,46 @@ export default {
|
||||
if (!this.isWideScreen) {
|
||||
this.$emit('visible-change', newVal)
|
||||
}
|
||||
},
|
||||
currentPath: {
|
||||
handler() {
|
||||
this.syncExpandedWithRoute()
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkScreenSize()
|
||||
this.checkCustomReports()
|
||||
},
|
||||
onShow() {
|
||||
// 每次显示时检查屏幕尺寸
|
||||
this.checkScreenSize()
|
||||
this.checkCustomReports()
|
||||
},
|
||||
methods: {
|
||||
isActive(path: string): boolean {
|
||||
const cur = this.currentPath || ''
|
||||
if (cur === path) return true
|
||||
if (cur.startsWith(path + '?')) return true
|
||||
if (cur.startsWith(path + '/')) return true
|
||||
return false
|
||||
},
|
||||
|
||||
async checkCustomReports() {
|
||||
try {
|
||||
const uid = getUserIdOrNull()
|
||||
if (uid == null || uid.length === 0) {
|
||||
this.hasAnyCustomReport = false
|
||||
return
|
||||
}
|
||||
const list = await listCustomReports(uid)
|
||||
this.hasAnyCustomReport = Array.isArray(list) && list.length > 0
|
||||
} catch (e) {
|
||||
this.hasAnyCustomReport = false
|
||||
}
|
||||
},
|
||||
|
||||
checkScreenSize() {
|
||||
// 获取屏幕宽度
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
@@ -104,6 +163,78 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
isExpanded(path: string): boolean {
|
||||
const v: any = (this as any).expanded
|
||||
return v != null && v[path] === true
|
||||
},
|
||||
|
||||
toggleExpanded(path: string) {
|
||||
const v: any = (this as any).expanded
|
||||
if (v == null) return
|
||||
v[path] = !(v[path] === true)
|
||||
;(this as any).expanded = { ...v }
|
||||
},
|
||||
|
||||
syncExpandedWithRoute() {
|
||||
for (let i = 0; i < this.menuItems.length; i++) {
|
||||
const item: any = this.menuItems[i]
|
||||
if (item.children && item.children.length > 0) {
|
||||
let shouldExpand = false
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const child = item.children[j]
|
||||
if (this.isActive(child.path)) {
|
||||
shouldExpand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.isActive(item.path)) {
|
||||
shouldExpand = true
|
||||
}
|
||||
const v: any = (this as any).expanded
|
||||
v[item.path] = shouldExpand
|
||||
}
|
||||
}
|
||||
|
||||
const v: any = (this as any).expanded
|
||||
;(this as any).expanded = { ...v }
|
||||
},
|
||||
|
||||
isCustomReportChildDisabled(parent: any, child: any): boolean {
|
||||
if (parent == null || child == null) return false
|
||||
if (parent.path !== '/pages/mall/analytics/custom-report') return false
|
||||
return this.hasAnyCustomReport !== true
|
||||
},
|
||||
|
||||
onChildClick(parent: any, child: any) {
|
||||
if (this.isCustomReportChildDisabled(parent, child)) {
|
||||
uni.showToast({ title: '请先创建自定义报表', icon: 'none', duration: 2000 })
|
||||
// 引导去自定义报表页
|
||||
this.navigateToPage('/pages/mall/analytics/custom-report')
|
||||
return
|
||||
}
|
||||
this.navigateToPage(child.path)
|
||||
},
|
||||
|
||||
onParentClick(item: any) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 有子菜单:
|
||||
// - 如果当前就在该父级页面:切换展开/收起
|
||||
// - 否则:先跳转到父级页面(自定义报表列表),并确保展开
|
||||
if (this.isActive(item.path)) {
|
||||
this.toggleExpanded(item.path)
|
||||
} else {
|
||||
const v: any = (this as any).expanded
|
||||
if (v != null) {
|
||||
v[item.path] = true
|
||||
;(this as any).expanded = { ...v }
|
||||
}
|
||||
this.navigateToPage(item.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.navigateToPage(item.path)
|
||||
},
|
||||
|
||||
closeMenu() {
|
||||
// 宽屏时不允许关闭
|
||||
if (this.isWideScreen) {
|
||||
@@ -121,10 +252,16 @@ export default {
|
||||
}
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
uni.redirectTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
// navigateTo 失败时通常是页面栈满(最多10层),这里降级为 redirectTo
|
||||
uni.navigateTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// 窄屏时关闭菜单
|
||||
@@ -223,6 +360,20 @@ export default {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.menu-item-child {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.menu-caret {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
@@ -232,6 +383,15 @@ export default {
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-item.active .menu-text {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
|
||||
325
docs/AGENT_PROJECT_SPEC.md
Normal file
325
docs/AGENT_PROJECT_SPEC.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Agent 项目规范文档(uni-app / uvue / uts)
|
||||
|
||||
## 0. 文档目标与适用范围
|
||||
|
||||
- **目标**:统一项目目录、命名、依赖边界、数据库(SQL/RLS/RPC)安全策略与准入流程,使 Agent 在改动代码/SQL 时遵循一致的工程约束与安全基线。
|
||||
- **适用范围**:
|
||||
- 前端:`pages/`、`components/`、`layouts/`、`services/`、`utils/`、`types/`、`uni_modules/`、`static/`
|
||||
- 文档与数据库:`docs/`、`docs/sql/`、模块内 `test/` SQL
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目核心结构与职责边界
|
||||
|
||||
### 1.1 页面与路由
|
||||
|
||||
- **权威路由配置**:根目录 `pages.json`
|
||||
- **分包规则**:
|
||||
- `tabBar`:主入口页面(消费者端)
|
||||
- `subPackages`:按模块分包(consumer 非 tab 页、delivery、analytics、admin、merchant、service 等模块)
|
||||
- **页面目录**:`pages/`
|
||||
- `pages/user/`:登录/注册/用户中心等公共页面
|
||||
- `pages/mall/<module>/`:各业务模块(consumer/delivery/analytics/admin/merchant/service...)
|
||||
- **规则**:
|
||||
- `pages/` 内只放页面(路由入口)与页面级组合逻辑
|
||||
- 可复用 UI/逻辑必须下沉到 `components/` / `services/` / `utils/` / `types/`
|
||||
|
||||
### 1.2 布局
|
||||
|
||||
- **目录**:`layouts/`
|
||||
- **规则**:
|
||||
- layout 只负责结构与 slot/容器
|
||||
- layout 不直接写重业务数据请求(可触发初始化但不承担领域逻辑)
|
||||
|
||||
### 1.3 组件
|
||||
|
||||
- **目录**:`components/`
|
||||
- **推荐分层(可渐进迁移)**:
|
||||
- `components/base/`:无业务语义基础组件(按钮、弹窗、表单控件、空状态等)
|
||||
- `components/biz/<domain>/`:带业务语义组件(订单卡片、SKU 选择器等)
|
||||
- `components/integration/<vendor>/`:第三方/平台集成组件(如 supabase、analytics SDK 外观层)
|
||||
- **规则**:
|
||||
- 组件不得直接读写数据库或拼接 SQL
|
||||
- 网络/数据访问必须通过 `services/`(数据访问唯一入口)
|
||||
|
||||
### 1.4 服务层(数据访问唯一入口)
|
||||
|
||||
- **目录**:`services/`
|
||||
- **职责**:
|
||||
- RPC/API 调用封装
|
||||
- 鉴权、token 注入、错误标准化处理
|
||||
- 数据转换(DTO -> ViewModel 允许在这里或页面层,但必须统一口径)
|
||||
- **规则**:
|
||||
- 页面/组件不得直接访问底层 client(如 supabase client),必须经 `services/` 统一出口
|
||||
|
||||
### 1.5 工具与类型
|
||||
|
||||
- **目录**:
|
||||
- `utils/`:纯工具函数(尽量无 IO、无全局状态)
|
||||
- `types/`:全局类型、领域模型、DTO、服务返回类型
|
||||
- **规则**:
|
||||
- `utils/` 不反向依赖业务模块目录
|
||||
- `types/` 不依赖运行时代码(保持纯类型)
|
||||
|
||||
### 1.6 插件/可替换模块
|
||||
|
||||
- **目录**:`uni_modules/`
|
||||
- **定位**:可独立发布/可替换的插件能力(组件、uts sdk、集成适配等)
|
||||
- **规则**:
|
||||
- 不把所有业务通用代码塞到 `uni_modules/`;业务通用优先 `services/utils/components`
|
||||
|
||||
---
|
||||
|
||||
## 2. 路径别名与引用规范
|
||||
|
||||
### 2.1 当前事实
|
||||
|
||||
- `tsconfig.json` 已配置:`@/* -> ./*`
|
||||
|
||||
### 2.2 统一引用建议(Agent 必须遵循)
|
||||
|
||||
- 默认使用 `@/` 前缀引用项目内代码(例如 `@/services/...`、`@/components/...`)
|
||||
- 不强制引入 `@components`、`@uni_modules` 这类新 alias,除非后续明确要落地到配置文件;当前以目录语义规范为准
|
||||
|
||||
---
|
||||
|
||||
## 3. 依赖方向(强约束)
|
||||
|
||||
为避免循环依赖与业务污染,依赖方向必须满足:
|
||||
|
||||
- `pages` -> 可依赖 `components/services/utils/types/layouts`
|
||||
- `components` -> 可依赖 `services/utils/types`
|
||||
- `layouts` -> 可依赖 `components/utils/types`(避免重业务)
|
||||
- `services` -> 只依赖 `utils/types`(不得依赖 pages/components)
|
||||
- `utils` -> 不依赖业务目录
|
||||
- `types` -> 不依赖运行时代码
|
||||
|
||||
---
|
||||
|
||||
## 4. 命名与文件组织规范
|
||||
|
||||
- **目录名**:全小写,必要时用连字符(全仓统一一种风格)
|
||||
- **组件文件**:`PascalCase.uvue`
|
||||
- **服务文件**:统一 `xxxService.uts`
|
||||
- **SQL 文件**:
|
||||
- 测试阶段:模块 `test/` 下自由命名,但必须含用途前缀
|
||||
- 权威阶段:进入 `docs/sql/` 后必须按分层目录 + 版本号命名(见第 6 节)
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库/权限权威口径(与前端联动)
|
||||
|
||||
本项目数据库权限与前端路由/页面访问形成闭环。以下为强制口径:
|
||||
|
||||
- **角色字段唯一权威**:`public.ak_users.role`
|
||||
- **analytics/admin 的全局数据访问**:必须通过 RPC 完成,避免直接开放业务表全局权限
|
||||
- **前端必须做客户端守卫**:
|
||||
- 访问 analytics/admin 页面入口时,必须校验登录与角色(例如 `ensureRole(['admin','analytics'])`)
|
||||
- 客户端守卫只用于“快速失败”,最终权限以数据库侧为准
|
||||
|
||||
---
|
||||
|
||||
## 6. SQL 两阶段工作流(模块测试 -> 权威入库)
|
||||
|
||||
### 6.1 阶段 A:模块内测试 SQL(非权威)
|
||||
|
||||
- **位置**:各模块目录 `test/`
|
||||
- 例:`pages/mall/analytics/test/*.sql`
|
||||
- **允许**:快速迭代、验证思路、临时脚本
|
||||
- **禁止**:高危破坏性操作(见 7.3)
|
||||
|
||||
### 6.2 阶段 B:入库到 `docs/sql/`(权威)
|
||||
|
||||
- **位置**:`docs/sql/`
|
||||
- **入库准入条件**:
|
||||
- 必须通过 Agent 安全评估(输出评审报告)
|
||||
- 必须符合本项目角色/RLS/RPC 安全口径(见 7.2;并强制对照 `docs/sql/11_roles_and_permissions_strategy.md`)
|
||||
- 建议至少 1 人人工确认后再合并
|
||||
|
||||
### 6.3 `docs/sql/` 分层(权威目录结构)
|
||||
|
||||
为保证可审计、可复用与最小风险暴露,进入权威目录的 SQL 必须按“对象类型”拆分归档,禁止将多种对象(表 + RLS + RPC + GRANT 等)长期混放在同一个文件中。
|
||||
|
||||
- `docs/sql/00_meta/`:规范/策略/说明类(如角色与权限策略)
|
||||
- `docs/sql/10_schema/`:表/类型/索引等 DDL
|
||||
- 建议进一步按域分组:`docs/sql/10_schema/<domain>/...`
|
||||
- `docs/sql/20_rls/`:RLS enable + policies(按表/域拆分)
|
||||
- 建议进一步按域分组:`docs/sql/20_rls/<domain>/...`
|
||||
- `docs/sql/30_rpc/`:RPC/函数(尤其 analytics)
|
||||
- 建议进一步按域分组:`docs/sql/30_rpc/<domain>/...`
|
||||
- `docs/sql/40_grants/`:显式授权(GRANT/REVOKE)
|
||||
- 仅允许最小权限;对 `anon/authenticated` 的任何授权必须附带评审结论
|
||||
- `docs/sql/90_archive/`:历史/废弃(只读,不再作为引用口径)
|
||||
|
||||
**对象拆分规则(入库必选一类)**:
|
||||
|
||||
- **表/类型/索引**:只放 DDL(`CREATE/ALTER TABLE/TYPE/INDEX`),归入 `10_schema`
|
||||
- **RLS 策略**:只放 `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` 及 `CREATE POLICY/ALTER POLICY/DROP POLICY`,归入 `20_rls`
|
||||
- **函数/RPC**:只放 `CREATE OR REPLACE FUNCTION ...`(含 `SECURITY DEFINER` 等),归入 `30_rpc`
|
||||
- **授权**:只放 `GRANT/REVOKE`,归入 `40_grants`
|
||||
|
||||
**例外(允许同文件)**:
|
||||
|
||||
- 同一对象的“紧耦合小变更”可以同文件(例如同一个函数的创建 + 注释/owner 设置),但不得混入其他对象类型。
|
||||
|
||||
### 6.4 权威 SQL 入库步骤(从模块 test/ 迁移)
|
||||
|
||||
当模块内 `test/` SQL 验证通过、准备进入 `docs/sql/` 时,必须按以下步骤执行:
|
||||
|
||||
- **步骤 0:目录存在性检查(必须)**
|
||||
- 若以下目录不存在,Agent 必须创建;若已存在则复用,禁止新建“近似重复目录”。
|
||||
- 目录清单:
|
||||
- `docs/sql/00_meta/`
|
||||
- `docs/sql/10_schema/`
|
||||
- `docs/sql/20_rls/`
|
||||
- `docs/sql/30_rpc/`
|
||||
- `docs/sql/40_grants/`
|
||||
- `docs/sql/90_archive/`
|
||||
- 如采用域分组(`<domain>`),对应的 `docs/sql/<layer>/<domain>/` 目录同样遵循“无则创建,有则复用”。
|
||||
|
||||
- **步骤 1:对象识别与拆分**
|
||||
- 将 SQL 按对象类型拆分为:schema / rls / rpc / grants(必要时再细分 domain)
|
||||
|
||||
- **步骤 2:命名与归档路径确定**
|
||||
- 表/DDL:`docs/sql/10_schema/<domain>/<object>_v<version>.sql`
|
||||
- RLS:`docs/sql/20_rls/<domain>/<table>_rls_v<version>.sql`
|
||||
- RPC:`docs/sql/30_rpc/<domain>/<rpc_name>_v<version>.sql`
|
||||
- Grants:`docs/sql/40_grants/<domain>/<scope>_grants_v<version>.sql`
|
||||
|
||||
- **步骤 3:安全评审(Agent 必须输出评审报告)**
|
||||
- 评审必须强制对照 `docs/sql/11_roles_and_permissions_strategy.md`
|
||||
- 结论为 `OK` 方可入库
|
||||
|
||||
- **步骤 4:入库与引用口径**
|
||||
- 入库后,模块 `test/` 中对应脚本仅保留为测试/回归用途(不得再作为权威引用)
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent SQL 安全评估制度(准入制)
|
||||
|
||||
### 7.1 评估结论(必须输出其一)
|
||||
|
||||
- `Reject`:拒绝入库
|
||||
- `High`:高风险,必须整改后复评
|
||||
- `OK`:可入库
|
||||
|
||||
### 7.2 项目强制安全要求(RPC/analytics)
|
||||
|
||||
所有 `rpc_analytics_*`(及类似特权 RPC)必须满足:
|
||||
|
||||
- `SECURITY DEFINER`
|
||||
- `SET search_path = public`(固定 search_path)
|
||||
- 函数入口显式鉴权:
|
||||
- `get_current_user_role() IN ('admin','analytics')` 才允许执行
|
||||
- 返回字段最小化(只返回统计必要字段/聚合结果)
|
||||
|
||||
**强制参照文档**:所有 SQL 评审必须对照并满足 `docs/sql/11_roles_and_permissions_strategy.md` 中的角色定义、RLS 分层、RPC 安全闭环要求。
|
||||
|
||||
### 7.3 硬阻断(出现任意一条:Reject)
|
||||
|
||||
- **裸放权**:对 `anon/authenticated` 大范围 `GRANT` 且无等价约束
|
||||
- **破坏性操作**:
|
||||
- `DROP TABLE/SCHEMA/ROLE/EXTENSION`
|
||||
- `TRUNCATE`
|
||||
- 大范围 `DELETE/UPDATE` 无可靠 `WHERE`
|
||||
- **不安全的 SECURITY DEFINER**:
|
||||
- 无入口鉴权
|
||||
- 未固定 `search_path`
|
||||
- **绕过 RLS 的不透明入口**:绕过后无角色校验/无最小返回字段
|
||||
|
||||
### 7.4 高风险需整改(High)
|
||||
|
||||
- RLS 覆盖不全(业务需要写但未覆盖 `INSERT/UPDATE/DELETE`)
|
||||
- policy 条件过宽(如 `USING (true)`)
|
||||
- RPC 返回敏感字段
|
||||
- 无分页/无 LIMIT 约束造成全量泄露或性能风险
|
||||
|
||||
---
|
||||
|
||||
## 8. Agent SQL 评审报告模板(固定输出)
|
||||
|
||||
### SQL 安全评审报告
|
||||
|
||||
- **对象**:`<文件路径>`
|
||||
- **目标**:`<此 SQL 的目的>`
|
||||
- **结论**:`Reject | High | OK`
|
||||
- **涉及对象**:
|
||||
- 表/视图/函数:`...`
|
||||
- 角色/权限:`...`
|
||||
- RLS:是否启用/修改;覆盖哪些操作
|
||||
- `SECURITY DEFINER`:是/否;入口鉴权:是/否;`search_path` 固定:是/否
|
||||
- **与项目口径一致性(强制对照 docs/sql/11)**:
|
||||
- admin/analytics 是否仅通过 RPC 获取全局数据:是/否
|
||||
- **风险点列表**:
|
||||
- `等级` + `定位(片段/行号)` + `原因`
|
||||
- **整改建议**:
|
||||
- 可执行修改建议清单
|
||||
- **准入建议**:
|
||||
- 是否允许进入 `docs/sql/`:是/否;若否:进入条件
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有 SQL/文档目录的边界(避免双真相)
|
||||
|
||||
仓库存在多处 SQL/迁移相关目录(如 `mall_sql/`、`doc_mall/`)。必须明确:
|
||||
|
||||
- **权威策略/权威脚本口径**:以 `docs/sql/` 为准
|
||||
- 其他目录若属于迁移脚本仓/历史产物:
|
||||
- 必须标注用途(迁移工具、一次性脚本、报告存档)
|
||||
- 不允许出现与 `docs/sql/` 并行的“第二份权威定义”
|
||||
|
||||
---
|
||||
|
||||
## 10. 操作文档(强制)
|
||||
|
||||
任何会对项目产生可观察影响的操作,Agent 必须同步编写“操作文档”,将操作过程与结果可描述化、可审计化。
|
||||
|
||||
### 10.1 何时必须写操作文档
|
||||
|
||||
- 修改/新增/删除任何代码文件(`.uvue/.uts/.ts/.js/.json/.scss` 等)
|
||||
- 修改 `pages.json`、`manifest.json`、`package.json`、`tsconfig.json` 等配置
|
||||
- 新增/调整 SQL(包括模块 `test/` 与 `docs/sql` 权威入库)
|
||||
- 任何涉及权限/鉴权/RLS/RPC/GRANT 的变更
|
||||
|
||||
### 10.2 操作文档存放位置(从当前操作目录向上查找)
|
||||
|
||||
存放规则:从“当前正在操作的目录”开始,逐层向上寻找 `docs/` 目录,直到“当前模块根目录”为止。
|
||||
|
||||
- 若在某一层找到 `docs/`:将操作文档写入该 `docs/` 下的 `ops/` 子目录。
|
||||
- 若一路向上直到模块根目录仍未找到 `docs/`:Agent 必须在模块根目录创建 `docs/ops/` 并写入。
|
||||
- 若目标 `docs/ops/` 不存在:Agent 必须创建;若存在则复用。
|
||||
|
||||
**模块根目录判定**(满足任一即可认为到达模块根):
|
||||
|
||||
- 存在 `pages.json`(项目根)
|
||||
- 或进入了业务模块目录(例如 `pages/mall/analytics/`、`pages/mall/admin/`、`pages/user/`)的顶层
|
||||
- 或进入了公共模块目录(例如 `services/`、`components/`、`utils/`、`types/`、`uni_modules/<name>/`)的顶层
|
||||
|
||||
### 10.3 公共模块 vs 业务模块 的归档规则
|
||||
|
||||
- **业务模块操作**:文档归档到该业务模块自己的 `docs/ops/`
|
||||
- 例:操作发生在 `pages/mall/analytics/...`,则优先归档到 `pages/mall/analytics/docs/ops/`
|
||||
- **公共模块操作**:文档归档到对应公共模块自己的 `docs/ops/`
|
||||
- 例:操作发生在 `services/...`,则归档到 `services/docs/ops/`
|
||||
- 例:操作发生在 `uni_modules/<name>/...`,则归档到 `uni_modules/<name>/docs/ops/`
|
||||
|
||||
### 10.4 操作文档命名规范
|
||||
|
||||
- 文件名:`YYYY-MM-DD__<scope>__<short-title>.md`
|
||||
- `scope` 建议值:
|
||||
- `analytics` / `admin` / `consumer` / `merchant` / `delivery` / `user`
|
||||
- `services` / `components` / `utils` / `types` / `uni_modules-<name>`
|
||||
|
||||
### 10.5 操作文档最小内容模板
|
||||
|
||||
- **摘要**:做了什么
|
||||
- **动机**:为什么要做
|
||||
- **影响范围**:涉及哪些模块/页面/接口/权限
|
||||
- **变更清单**:
|
||||
- 新增文件:...
|
||||
- 修改文件:...
|
||||
- 删除文件:...
|
||||
- **兼容性与风险**:可能的副作用、回滚风险
|
||||
- **回滚方案**:如何撤销、恢复到原状态
|
||||
- **验证方式**:如何验证改动正确(手工步骤/测试点)
|
||||
- **关联文档**:例如 `docs/sql/11_roles_and_permissions_strategy.md` 或对应评审报告
|
||||
32
docs/sql/00_overview.md
Normal file
32
docs/sql/00_overview.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 00 概览:商城数据库总体设计
|
||||
|
||||
## 目标与定位
|
||||
|
||||
本数据库设计面向 **PostgreSQL + Supabase** 的电商/订阅混合业务,核心目标:
|
||||
|
||||
- **统一用户体系复用**:复用 `public.ak_users`,商城域只做扩展(`ml_` 前缀)。
|
||||
- **安全优先(Supabase 直连友好)**:使用 RLS(Row Level Security)+ `auth.uid()` 做行级数据隔离。
|
||||
- **对外访问友好(SEO / URL)**:核心表同时提供 `UUID id`(内部主键)与 `SERIAL cid`(对外可读 ID),配合 `slug` 与 SEO 函数。
|
||||
- **数据库承载关键一致性**:触发器/函数/约束实现 `updated_at` 自动维护、默认地址唯一、库存汇总、订单状态时间戳与销量累计等。
|
||||
- **快速迭代与可扩展**:大量使用 `JSONB` 承载可变结构(属性、媒体、地址快照、适用范围、订阅 features/metadata 等)。
|
||||
|
||||
## SQL 资料来源
|
||||
|
||||
- 迁移/建库主脚本:
|
||||
- `doc_mall/database/mall_migration.sql`(偏幂等、增量迁移)
|
||||
- `doc_mall/database/complete_mall_database.sql`(偏一次性完整初始化,包含更多视图/函数/RLS/SEO)
|
||||
- 订阅模块:`doc_mall/create_mall_subscription_tables.sql`
|
||||
- 检查/测试脚本:`mall_sql/tests/mall_database_check.sql` 等
|
||||
|
||||
## 术语
|
||||
|
||||
- **SPU**:商品主表(本库对应 `ml_products`)
|
||||
- **SKU**:商品规格明细(本库对应 `ml_product_skus`)
|
||||
- **RLS**:Row Level Security(行级安全策略)
|
||||
|
||||
## 一句话总结
|
||||
|
||||
- **Supabase 优先**(RLS + `auth.uid()`)
|
||||
- **快速迭代优先**(JSONB + 配置化)
|
||||
- **数据库保证关键一致性**(触发器/函数/约束/视图)
|
||||
- **对外可读与 SEO 友好**(`UUID id` + `SERIAL cid` + `slug`)
|
||||
236
docs/sql/01_tables_catalog.md
Normal file
236
docs/sql/01_tables_catalog.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 01 表清单与职责划分(按业务域)
|
||||
|
||||
本节将 `ml_` 前缀的商城域表按业务域归类,并给出每张表的核心职责、关键字段与主要关联。
|
||||
|
||||
> 说明:用户主表复用 `public.ak_users`,商城域通过外键 `user_id/merchant_id` 关联。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户域(Account Extension)
|
||||
|
||||
### 1.1 `ml_user_profiles`(用户扩展档案)
|
||||
|
||||
- **职责**:承载商城侧的用户扩展信息(状态、实名、信用分、认证信息、偏好等)。
|
||||
- **关键字段**:
|
||||
- `user_id`:外键到 `ak_users(id)`(且 `UNIQUE`,保证一用户一档案)
|
||||
- `status`:用户状态(正常/冻结/注销/待审核)
|
||||
- `verification_status`、`verification_data`:认证状态及数据(JSONB)
|
||||
- `preferences`:用户偏好(JSONB)
|
||||
- **主要关联**:
|
||||
- `ml_user_profiles.user_id -> ak_users.id`
|
||||
|
||||
### 1.2 `ml_user_addresses`(用户地址)
|
||||
|
||||
- **职责**:用户收货地址(并支持默认地址)。
|
||||
- **关键字段**:
|
||||
- `user_id`:外键到 `ak_users(id)`
|
||||
- `is_default`:是否默认地址(由触发器确保单一默认)
|
||||
- `province/city/district/address_detail`:地址结构化字段
|
||||
- **主要关联**:
|
||||
- `ml_user_addresses.user_id -> ak_users.id`
|
||||
|
||||
---
|
||||
|
||||
## 2. 商品域(Catalog)
|
||||
|
||||
### 2.1 `ml_categories`(商品分类)
|
||||
|
||||
- **职责**:商品分类树,支持 SEO(`cid/slug`)与层级路径。
|
||||
- **关键字段**:
|
||||
- `parent_id`:自关联
|
||||
- `path TEXT[]`:分类路径(便于面包屑、筛选等)
|
||||
- `cid`:对外友好 ID
|
||||
- `slug`:SEO slug
|
||||
- **主要关联**:
|
||||
- `ml_categories.parent_id -> ml_categories.id`
|
||||
|
||||
### 2.2 `ml_brands`(品牌)
|
||||
|
||||
- **职责**:品牌维度,支持 SEO(`cid`)。
|
||||
- **关键字段**:`name/logo_url/is_active/cid`
|
||||
|
||||
### 2.3 `ml_products`(商品 SPU)
|
||||
|
||||
- **职责**:商品主表(SPU),包含:定价、库存汇总、SEO、属性、统计。
|
||||
- **关键字段**:
|
||||
- `merchant_id`:商家(关联 `ak_users`)
|
||||
- `category_id`、`brand_id`
|
||||
- `base_price/market_price/cost_price`
|
||||
- `total_stock/available_stock`(由 SKU 触发器汇总维护)
|
||||
- `status`(上架/下架/草稿/删除)
|
||||
- `image_urls/video_urls/attributes`(JSONB)
|
||||
- `cid/slug/seo_*`
|
||||
- **主要关联**:
|
||||
- `ml_products.merchant_id -> ak_users.id`
|
||||
- `ml_products.category_id -> ml_categories.id`
|
||||
- `ml_products.brand_id -> ml_brands.id`
|
||||
|
||||
### 2.4 `ml_product_skus`(商品 SKU)
|
||||
|
||||
- **职责**:SKU 明细:规格组合、SKU 价格与库存。
|
||||
- **关键字段**:
|
||||
- `product_id`:所属 SPU
|
||||
- `specifications JSONB`:规格组合(例如 `{color:"black", size:"M"}`)
|
||||
- `price/stock/status`
|
||||
- **主要关联**:
|
||||
- `ml_product_skus.product_id -> ml_products.id`
|
||||
|
||||
### 2.5 `ml_product_specs`(商品规格定义)
|
||||
|
||||
- **职责**:描述一个商品有哪些规格项及可选值(用于生成 SKU)。
|
||||
- **关键字段**:
|
||||
- `spec_name`(如 颜色/尺寸)
|
||||
- `spec_values JSONB`(如 `["black","white"]`)
|
||||
|
||||
---
|
||||
|
||||
## 3. 店铺域(Merchant/Shop)
|
||||
|
||||
### `ml_shops`(店铺)
|
||||
|
||||
- **职责**:店铺信息(当前模型约束“一商家一店”)。
|
||||
- **关键字段**:
|
||||
- `merchant_id UNIQUE`:一对一约束
|
||||
- `status`(正常/暂停/关闭)
|
||||
- `address/business_hours`(JSONB)
|
||||
- `rating_avg/rating_count/product_count/order_count`(统计类)
|
||||
|
||||
---
|
||||
|
||||
## 4. 交易域(Order/Trade)
|
||||
|
||||
### 4.1 `ml_orders`(订单主表)
|
||||
|
||||
- **职责**:订单交易核心,含金额、地址快照、状态机、关键时间点。
|
||||
- **关键字段**:
|
||||
- `order_no`:订单号(可由序列+函数生成)
|
||||
- `user_id`:买家
|
||||
- `merchant_id`:商家(当前单商家订单模型)
|
||||
- 金额拆分:`product_amount/discount_amount/shipping_fee/total_amount/paid_amount`
|
||||
- `shipping_address JSONB`:下单快照地址
|
||||
- 状态机:`order_status/payment_status/shipping_status`
|
||||
- 时间点:`paid_at/shipped_at/delivered_at/completed_at`
|
||||
|
||||
### 4.2 `ml_order_items`(订单明细)
|
||||
|
||||
- **职责**:订单行项目,保存下单快照(防止商品信息后改影响历史订单)。
|
||||
- **关键字段**:
|
||||
- `order_id` 外键
|
||||
- `product_id/sku_id`
|
||||
- `product_name/sku_name/specifications/image_url/price`(快照冗余)
|
||||
- `quantity/total_amount`
|
||||
|
||||
---
|
||||
|
||||
## 5. 购物车域
|
||||
|
||||
### `ml_shopping_cart`
|
||||
|
||||
- **职责**:购物车行项目。
|
||||
- **关键字段**:
|
||||
- `user_id/product_id/sku_id`
|
||||
- `quantity`、`selected`
|
||||
- `UNIQUE(user_id, product_id, sku_id)`:避免重复行
|
||||
|
||||
---
|
||||
|
||||
## 6. 营销域(Coupon)
|
||||
|
||||
### 6.1 `ml_coupon_templates`(优惠券模板)
|
||||
|
||||
- **职责**:券模板定义;支持平台券(`merchant_id` 为空)与商家券。
|
||||
- **关键字段**:
|
||||
- `coupon_type`(满减/折扣/免邮)
|
||||
- `discount_type`(固定金额/百分比)
|
||||
- `discount_value/min_order_amount/max_discount_amount`
|
||||
- `applicable_products/applicable_categories`(JSONB)
|
||||
- `start_time/end_time/status`
|
||||
|
||||
### 6.2 `ml_user_coupons`(用户优惠券)
|
||||
|
||||
- **职责**:用户领取的券实例,包含券码与使用归因。
|
||||
- **关键字段**:
|
||||
- `coupon_code`(唯一)
|
||||
- `status`(未用/已用/过期)
|
||||
- `used_at/order_id`
|
||||
|
||||
---
|
||||
|
||||
## 7. 履约域(Delivery)
|
||||
|
||||
### 7.1 `ml_delivery_drivers`(配送员)
|
||||
|
||||
- **职责**:配送员信息与工作状态。
|
||||
- **关键字段**:
|
||||
- `user_id UNIQUE`:一个用户对应一个配送员档案
|
||||
- `work_status/status`、位置信息、统计与评分
|
||||
|
||||
### 7.2 `ml_delivery_tasks`(配送任务)
|
||||
|
||||
- **职责**:配送任务与订单 1:1 绑定。
|
||||
- **关键字段**:
|
||||
- `order_id UNIQUE`:一个订单最多一个配送任务
|
||||
- `driver_id`
|
||||
- `pickup_address/delivery_address`(JSONB 快照)
|
||||
- `status`、`assigned_at/picked_at/delivered_at`
|
||||
|
||||
---
|
||||
|
||||
## 8. 评价域(Review)
|
||||
|
||||
### `ml_product_reviews`
|
||||
|
||||
- **职责**:商品评价;通过 `order_id + order_item_id` 强绑定订单来源。
|
||||
- **关键字段**:
|
||||
- `order_id`、`order_item_id`
|
||||
- `rating/content/images`
|
||||
- `merchant_reply/merchant_replied_at`
|
||||
|
||||
---
|
||||
|
||||
## 9. 用户行为域(Behavior)
|
||||
|
||||
### 9.1 `ml_user_favorites`(收藏)
|
||||
|
||||
- **职责**:收藏(多态目标:商品/店铺)。
|
||||
- **关键字段**:
|
||||
- `target_type`(1 商品 / 2 店铺)
|
||||
- `target_id`
|
||||
- `UNIQUE(user_id, target_type, target_id)`
|
||||
|
||||
### 9.2 `ml_browse_history`(浏览历史)
|
||||
|
||||
- **职责**:浏览记录(当前模型倾向“同商品最后一次浏览覆盖”)。
|
||||
- **关键字段**:`UNIQUE(user_id, product_id)`
|
||||
|
||||
### 9.3 `ml_search_history`(搜索记录)
|
||||
|
||||
- **职责**:搜索日志(可用于热词/推荐/分析)。
|
||||
- **关键字段**:`keyword/result_count/ip_address/user_agent`
|
||||
|
||||
---
|
||||
|
||||
## 10. 配置与地区
|
||||
|
||||
### 10.1 `ml_system_configs`(系统配置)
|
||||
|
||||
- **职责**:配置中心(JSONB),如运费、佣金比例、订单自动确认天数等。
|
||||
- **关键字段**:`config_key` 唯一、`config_value JSONB`
|
||||
|
||||
### 10.2 `ml_regions`(地区)
|
||||
|
||||
- **职责**:地区树(省/市/区/街道)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 订阅模块(Subscription)
|
||||
|
||||
### 11.1 `ml_subscription_plans`(订阅套餐)
|
||||
|
||||
- **职责**:订阅计划定义(计费周期、价格、试用天数、features)。
|
||||
- **关键字段**:`plan_code` 唯一、`billing_period`、`features JSONB`
|
||||
|
||||
### 11.2 `ml_user_subscriptions`(用户订阅)
|
||||
|
||||
- **职责**:用户订阅实例(状态机、自动续费、下次扣费时间等)。
|
||||
- **关键字段**:`status`(trial/active/past_due/canceled/expired)、`auto_renew`、`metadata JSONB`
|
||||
147
docs/sql/02_relationships_er.md
Normal file
147
docs/sql/02_relationships_er.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 02 关系与 ER(文字版)
|
||||
|
||||
本节用“文字版 ER + 基数(1:1 / 1:N)”描述核心表关系,并提示哪些约束来自数据库(唯一约束/外键/触发器)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 统一用户体系(复用 `ak_users`)
|
||||
|
||||
- `ak_users` 作为统一用户主表,商城域表通过 `user_id/merchant_id` 外键关联。
|
||||
|
||||
> 重要前提:RLS 策略通过 `auth.uid()` 映射 `ak_users.auth_id`(详见 `05_rls_permissions_matrix.md`)。因此 `ak_users` 必须具备 `auth_id` 字段并保持唯一性(建议 `unique index`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户域
|
||||
|
||||
### 2.1 `ak_users` 1:1 `ml_user_profiles`
|
||||
|
||||
- **关系**:一用户一商城档案
|
||||
- **依据**:`ml_user_profiles.user_id UNIQUE NOT NULL REFERENCES ak_users(id)`
|
||||
|
||||
### 2.2 `ak_users` 1:N `ml_user_addresses`
|
||||
|
||||
- **关系**:一个用户多个地址
|
||||
- **默认地址约束**:同一用户最多一个 `is_default = true`
|
||||
- **依据**:触发器 `ensure_single_default_address()`(数据库层自动维护)
|
||||
|
||||
---
|
||||
|
||||
## 3. 店铺/商家域
|
||||
|
||||
### 3.1 `ak_users(merchant)` 1:1 `ml_shops`
|
||||
|
||||
- **关系**:一个商家一个店铺(当前模型)
|
||||
- **依据**:`ml_shops.merchant_id UNIQUE NOT NULL REFERENCES ak_users(id)`
|
||||
|
||||
> 影响:订单主表可直接记录 `merchant_id`,无需子订单拆分即可查询店铺信息。
|
||||
|
||||
---
|
||||
|
||||
## 4. 商品域
|
||||
|
||||
### 4.1 `ml_categories` 1:N `ml_categories`(自关联分类树)
|
||||
|
||||
- **关系**:父分类包含多个子分类
|
||||
- **依据**:`ml_categories.parent_id REFERENCES ml_categories(id)`
|
||||
|
||||
### 4.2 `ml_categories` 1:N `ml_products`
|
||||
|
||||
- **关系**:一个分类包含多个商品
|
||||
- **依据**:`ml_products.category_id NOT NULL REFERENCES ml_categories(id)`
|
||||
|
||||
### 4.3 `ml_brands` 1:N `ml_products`
|
||||
|
||||
- **关系**:一个品牌对应多个商品
|
||||
- **依据**:`ml_products.brand_id REFERENCES ml_brands(id)`(可空)
|
||||
|
||||
### 4.4 `ml_products` 1:N `ml_product_skus`
|
||||
|
||||
- **关系**:一个商品(SPU)有多个 SKU
|
||||
- **依据**:`ml_product_skus.product_id NOT NULL REFERENCES ml_products(id) ON DELETE CASCADE`
|
||||
|
||||
### 4.5 `ml_products` 1:N `ml_product_specs`
|
||||
|
||||
- **关系**:一个商品定义多个规格项
|
||||
- **依据**:`ml_product_specs.product_id NOT NULL REFERENCES ml_products(id) ON DELETE CASCADE`
|
||||
|
||||
### 4.6 库存汇总(触发器关系)
|
||||
|
||||
- **事件**:`ml_product_skus` INSERT/UPDATE/DELETE
|
||||
- **结果**:触发器 `update_product_stock()` 汇总更新 `ml_products.total_stock/available_stock`
|
||||
|
||||
---
|
||||
|
||||
## 5. 交易域
|
||||
|
||||
### 5.1 `ak_users(customer)` 1:N `ml_orders`
|
||||
|
||||
- **关系**:用户有多个订单
|
||||
- **依据**:`ml_orders.user_id NOT NULL REFERENCES ak_users(id)`
|
||||
|
||||
### 5.2 `ak_users(merchant)` 1:N `ml_orders`
|
||||
|
||||
- **关系**:商家有多个订单
|
||||
- **依据**:`ml_orders.merchant_id NOT NULL REFERENCES ak_users(id)`
|
||||
|
||||
> 当前订单模型为“单商家订单”(`ml_orders` 直接记录 `merchant_id`)。若要支持“一单多商家”,通常需要主/子订单拆分。
|
||||
|
||||
### 5.3 `ml_orders` 1:N `ml_order_items`
|
||||
|
||||
- **关系**:订单包含多个明细
|
||||
- **依据**:`ml_order_items.order_id NOT NULL REFERENCES ml_orders(id) ON DELETE CASCADE`
|
||||
|
||||
### 5.4 `ml_orders` 1:1 `ml_delivery_tasks`(当前)
|
||||
|
||||
- **关系**:一个订单最多一个配送任务
|
||||
- **依据**:`ml_delivery_tasks.order_id UNIQUE NOT NULL REFERENCES ml_orders(id)`
|
||||
|
||||
---
|
||||
|
||||
## 6. 营销域
|
||||
|
||||
### 6.1 `ml_coupon_templates` 1:N `ml_user_coupons`
|
||||
|
||||
- **关系**:一个模板可被多个用户领取
|
||||
- **依据**:`ml_user_coupons.template_id NOT NULL REFERENCES ml_coupon_templates(id)`
|
||||
|
||||
### 6.2 `ml_user_coupons` N:1 `ml_orders`(可选关联)
|
||||
|
||||
- **关系**:券在使用时关联订单
|
||||
- **依据**:`ml_user_coupons.order_id REFERENCES ml_orders(id)`(可空)
|
||||
|
||||
---
|
||||
|
||||
## 7. 评价域
|
||||
|
||||
### 7.1 `ml_orders` 1:N `ml_product_reviews`(概念上)
|
||||
|
||||
- **关系**:订单可产生多个评价(通常按明细评价)
|
||||
- **依据**:`ml_product_reviews.order_id NOT NULL REFERENCES ml_orders(id)`
|
||||
|
||||
### 7.2 `ml_order_items` 1:1/N `ml_product_reviews`
|
||||
|
||||
- **关系**:明细可对应评价(实现上可以 1:1,也可以允许追评,取决于业务约束)
|
||||
- **依据**:`ml_product_reviews.order_item_id NOT NULL REFERENCES ml_order_items(id)`
|
||||
|
||||
---
|
||||
|
||||
## 8. 行为域
|
||||
|
||||
- `ak_users` 1:N `ml_user_favorites`
|
||||
- `ak_users` 1:N `ml_browse_history`
|
||||
- `ak_users` 0..N `ml_search_history`(可匿名搜索时 user_id 为空)
|
||||
|
||||
---
|
||||
|
||||
## 9. 订阅域
|
||||
|
||||
### 9.1 `ml_subscription_plans` 1:N `ml_user_subscriptions`
|
||||
|
||||
- **关系**:一个套餐对应多个用户订阅实例
|
||||
- **依据**:`ml_user_subscriptions.plan_id REFERENCES ml_subscription_plans(id)`
|
||||
|
||||
### 9.2 `ak_users` 1:N `ml_user_subscriptions`
|
||||
|
||||
- **关系**:一个用户可有多个订阅记录(例如历史续费、升级降级)
|
||||
- **依据**:`ml_user_subscriptions.user_id`(当前脚本未声明外键到 `ak_users`,建议在项目侧补齐或在应用层保证一致性)
|
||||
213
docs/sql/03_enums_status_dict.md
Normal file
213
docs/sql/03_enums_status_dict.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 03 状态/枚举字典(统一口径)
|
||||
|
||||
本节汇总数据库中以 `INTEGER + CHECK` 或 `TEXT + CHECK` 形式出现的核心状态字段,给出建议的统一解释口径。
|
||||
|
||||
> 注意:部分状态值在 `mall_migration.sql` 与 `complete_mall_database.sql` 存在细微差异(例如订单取消/取货的命名)。本字典以“脚本中出现的实际取值范围”为准,并在差异处标注。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户与认证
|
||||
|
||||
### 1.1 `ml_user_profiles.status`
|
||||
|
||||
取值:`IN (1,2,3,4)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:冻结
|
||||
- `3`:注销
|
||||
- `4`:待审核
|
||||
|
||||
### 1.2 `ml_user_profiles.verification_status`
|
||||
|
||||
取值:`IN (0,1,2)`
|
||||
|
||||
- `0`:未认证
|
||||
- `1`:已认证
|
||||
- `2`:认证失败
|
||||
|
||||
### 1.3 `ml_user_addresses.status`
|
||||
|
||||
取值:`IN (1,2)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:禁用
|
||||
|
||||
---
|
||||
|
||||
## 2. 商品
|
||||
|
||||
### 2.1 `ml_products.status`
|
||||
|
||||
取值:`IN (1,2,3,4)`
|
||||
|
||||
- `1`:上架
|
||||
- `2`:下架
|
||||
- `3`:草稿
|
||||
- `4`:删除(逻辑删除)
|
||||
|
||||
### 2.2 `ml_product_skus.status`
|
||||
|
||||
取值:`IN (1,2)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:禁用
|
||||
|
||||
---
|
||||
|
||||
## 3. 店铺
|
||||
|
||||
### `ml_shops.status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:暂停
|
||||
- `3`:关闭
|
||||
|
||||
---
|
||||
|
||||
## 4. 订单(交易状态机)
|
||||
|
||||
> 订单存在三个并行状态字段:`order_status`(订单流程)、`payment_status`(支付/退款)、`shipping_status`(发货/物流)。
|
||||
|
||||
### 4.1 `ml_orders.order_status`
|
||||
|
||||
取值:`IN (1,2,3,4,5,6,7)`
|
||||
|
||||
- `1`:待付款
|
||||
- `2`:待发货(在 `complete` 脚本里也可能被解释为“已付款/待发货”)
|
||||
- `3`:待收货
|
||||
- `4`:已完成
|
||||
- `5`:已取消 / 已取货(不同脚本表述不一致,建议在业务层统一为“取消”或“自提完成”之一)
|
||||
- `6`:退款中
|
||||
- `7`:已退款
|
||||
|
||||
建议(文档口径):
|
||||
|
||||
- 若业务没有“自提/取货”流程,建议将 `5` 固化为“已取消”。
|
||||
- 若业务需要“自提/取货完成”,建议拆出更清晰的状态(例如新增 `8` 表示取货完成),并迁移更新 CHECK。
|
||||
|
||||
### 4.2 `ml_orders.payment_status`
|
||||
|
||||
取值:`IN (1,2,3,4)`
|
||||
|
||||
- `1`:未付款
|
||||
- `2`:已付款
|
||||
- `3`:部分退款
|
||||
- `4`:全额退款
|
||||
|
||||
### 4.3 `ml_orders.shipping_status`
|
||||
|
||||
取值:`IN (1,2,3,4)`
|
||||
|
||||
- `1`:未发货
|
||||
- `2`:已发货
|
||||
- `3`:运输中
|
||||
- `4`:已送达
|
||||
|
||||
---
|
||||
|
||||
## 5. 优惠券
|
||||
|
||||
### 5.1 `ml_coupon_templates.coupon_type`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:满减券
|
||||
- `2`:折扣券
|
||||
- `3`:免运费券
|
||||
|
||||
### 5.2 `ml_coupon_templates.discount_type`
|
||||
|
||||
取值:`IN (1,2)`
|
||||
|
||||
- `1`:固定金额
|
||||
- `2`:百分比
|
||||
|
||||
### 5.3 `ml_coupon_templates.status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:暂停
|
||||
- `3`:已结束
|
||||
|
||||
### 5.4 `ml_user_coupons.status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:未使用
|
||||
- `2`:已使用
|
||||
- `3`:已过期
|
||||
|
||||
---
|
||||
|
||||
## 6. 配送
|
||||
|
||||
### 6.1 `ml_delivery_drivers.vehicle_type`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:电动车
|
||||
- `2`:摩托车
|
||||
- `3`:汽车
|
||||
|
||||
### 6.2 `ml_delivery_drivers.work_status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:在线
|
||||
- `2`:忙碌
|
||||
- `3`:离线
|
||||
|
||||
### 6.3 `ml_delivery_drivers.status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:暂停
|
||||
- `3`:离职
|
||||
|
||||
### 6.4 `ml_delivery_tasks.status`
|
||||
|
||||
取值:`IN (1,2,3,4,5,6)`
|
||||
|
||||
- `1`:待接单
|
||||
- `2`:已接单
|
||||
- `3`:取货中
|
||||
- `4`:配送中
|
||||
- `5`:已送达
|
||||
- `6`:配送失败
|
||||
|
||||
---
|
||||
|
||||
## 7. 评价与行为
|
||||
|
||||
### 7.1 `ml_product_reviews.status`
|
||||
|
||||
取值:`IN (1,2,3)`
|
||||
|
||||
- `1`:正常
|
||||
- `2`:已删除
|
||||
- `3`:已隐藏
|
||||
|
||||
### 7.2 `ml_user_favorites.target_type`
|
||||
|
||||
取值:`IN (1,2)`
|
||||
|
||||
- `1`:商品
|
||||
- `2`:店铺
|
||||
|
||||
---
|
||||
|
||||
## 8. 订阅(Subscription)
|
||||
|
||||
### `ml_user_subscriptions.status`
|
||||
|
||||
取值:`IN ('trial','active','past_due','canceled','expired')`
|
||||
|
||||
- `trial`:试用中
|
||||
- `active`:生效中
|
||||
- `past_due`:逾期(扣费失败/欠费)
|
||||
- `canceled`:已取消
|
||||
- `expired`:已过期
|
||||
240
docs/sql/04_triggers_and_functions.md
Normal file
240
docs/sql/04_triggers_and_functions.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 04 触发器与函数(数据库承载的业务规则)
|
||||
|
||||
本节汇总数据库内实现的关键触发器与函数,说明:
|
||||
|
||||
- 它们解决什么业务问题
|
||||
- 触发时机是什么
|
||||
- 对数据一致性与性能的影响
|
||||
- 典型使用/触发示例
|
||||
|
||||
---
|
||||
|
||||
## 1. 通用触发器:自动维护 `updated_at`
|
||||
|
||||
### 1.1 `public.update_updated_at_column()`(complete 脚本)
|
||||
|
||||
- **目的**:统一把 `updated_at` 设置为当前时间,避免应用层漏写。
|
||||
- **触发时机**:`BEFORE UPDATE`
|
||||
|
||||
典型触发表(complete 脚本中出现):
|
||||
|
||||
- `ml_user_profiles`
|
||||
- `ml_user_addresses`
|
||||
- `ml_products`
|
||||
- `ml_product_skus`
|
||||
- `ml_shops`
|
||||
- `ml_orders`
|
||||
- `ml_shopping_cart`
|
||||
|
||||
触发效果示例:
|
||||
|
||||
```sql
|
||||
update public.ml_products
|
||||
set name = '新标题'
|
||||
where id = '...product_uuid...'::uuid;
|
||||
|
||||
-- updated_at 会自动变为 now()
|
||||
```
|
||||
|
||||
### 1.2 `public.set_updated_at()`(订阅脚本)
|
||||
|
||||
订阅模块在 `doc_mall/create_mall_subscription_tables.sql` 里定义了一个更轻量的 `set_updated_at()`,并对:
|
||||
|
||||
- `ml_subscription_plans`
|
||||
- `ml_user_subscriptions`
|
||||
|
||||
设置 `BEFORE UPDATE` 触发器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 地址一致性:默认地址唯一
|
||||
|
||||
### `public.ensure_single_default_address()`
|
||||
|
||||
- **目的**:保证同一个用户最多只有一个 `is_default = true` 的地址。
|
||||
- **触发时机**:`BEFORE INSERT OR UPDATE ON ml_user_addresses`
|
||||
- **核心逻辑**:当新行/更新行被设为默认时,把该用户其他地址全部置为非默认。
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
update public.ml_user_addresses
|
||||
set is_default = true
|
||||
where id = '...address_uuid...'::uuid;
|
||||
|
||||
-- 触发器会把同 user_id 的其他地址 is_default 设为 false
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 这是“业务规则下沉 DB”的典型。
|
||||
- 若存在并发更新两条地址为默认,最终仍会收敛为“最后提交事务的那条为默认”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 库存汇总:SKU 维护,SPU 汇总
|
||||
|
||||
### `public.update_product_stock()`
|
||||
|
||||
- **目的**:当 SKU 改变时,自动汇总刷新商品表的库存字段,避免每次展示都 `join + group by`。
|
||||
- **触发时机**:`AFTER INSERT OR UPDATE OR DELETE ON ml_product_skus`
|
||||
- **影响字段**:
|
||||
- `ml_products.total_stock`
|
||||
- `ml_products.available_stock`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
update public.ml_product_skus
|
||||
set stock = 8
|
||||
where id = '...sku_uuid...'::uuid;
|
||||
|
||||
-- 触发器会汇总该 product_id 下所有 status=1 的 SKU stock
|
||||
-- 并更新到 ml_products.total_stock / available_stock
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 这种“汇总字段”设计利于读性能,但写入 SKU 会产生额外更新 SPU 的成本。
|
||||
- 若 SKU 更新频率很高,需要评估热点商品写放大。
|
||||
|
||||
---
|
||||
|
||||
## 4. 订单状态副作用(complete 脚本)
|
||||
|
||||
### `public.handle_order_status_change()`
|
||||
|
||||
- **目的**:订单状态变化时,自动写入关键时间点,并在完成时累计销量。
|
||||
- **触发时机**:`BEFORE UPDATE ON ml_orders`
|
||||
|
||||
核心行为(按脚本逻辑):
|
||||
|
||||
- 从 `order_status: 1 -> 2`:写入 `paid_at = now()`
|
||||
- 从 `order_status: 2 -> 3`:写入 `shipped_at = now()`
|
||||
- 从 `order_status: 3 -> 4`:写入 `delivered_at/completed_at = now()`,并累计 `ml_products.sale_count`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
update public.ml_orders
|
||||
set order_status = 2
|
||||
where id = '...order_uuid...'::uuid;
|
||||
|
||||
-- paid_at 自动写入
|
||||
```
|
||||
|
||||
销量累计示例(订单完成):
|
||||
|
||||
```sql
|
||||
update public.ml_orders
|
||||
set order_status = 4
|
||||
where id = '...order_uuid...'::uuid;
|
||||
|
||||
-- 会对订单明细涉及的商品 sale_count 做累加
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 若业务上“支付状态”与“订单状态”不是严格 1->2 的映射,可能需要调整触发条件。
|
||||
- 累计销量属于“统计字段”,适合下沉 DB;但需要考虑退款/取消是否回滚销量(脚本未体现)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 生成类函数
|
||||
|
||||
### 5.1 `public.generate_order_no()` + `public.ml_order_seq`
|
||||
|
||||
- **目的**:生成业务订单号(形如 `MLYYYYMMDD000001`)。
|
||||
- **依赖**:序列 `ml_order_seq`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
select public.generate_order_no();
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 若订单号生成需要“并发唯一 + 分库分表友好”,可以进一步引入节点号/随机段。
|
||||
|
||||
### 5.2 `public.generate_coupon_code()`
|
||||
|
||||
- **目的**:生成券码(脚本中为 `CP` + 8 位随机字符)。
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
select public.generate_coupon_code();
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 随机生成+唯一约束在极端高并发下可能出现冲突重试需求(一般可接受)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询辅助函数
|
||||
|
||||
### 6.1 `public.get_user_default_address(p_user_id uuid)`
|
||||
|
||||
- **目的**:快速获取用户默认地址,并拼接 `full_address`。
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
select *
|
||||
from public.get_user_default_address('...user_uuid...'::uuid);
|
||||
```
|
||||
|
||||
### 6.2 `public.calculate_cart_total(p_user_id uuid)`
|
||||
|
||||
- **目的**:计算用户购物车选中商品的总金额(按 SKU 价 * 数量)。
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
select public.calculate_cart_total('...user_uuid...'::uuid);
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 该函数读取多表(cart + sku + product),并包含状态过滤。
|
||||
- 若购物车行数很多或频繁调用,需要关注执行计划与索引。
|
||||
|
||||
### 6.3 `public.get_product_available_stock(p_product_id uuid, p_sku_id uuid default null)`
|
||||
|
||||
- **目的**:查询商品或指定 SKU 的可用库存。
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
-- 查 SKU
|
||||
select public.get_product_available_stock('...product_uuid...'::uuid, '...sku_uuid...'::uuid);
|
||||
|
||||
-- 查商品汇总
|
||||
select public.get_product_available_stock('...product_uuid...'::uuid, null);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SEO 相关函数(complete 脚本)
|
||||
|
||||
- `get_product_by_cid(p_cid int)`
|
||||
- `get_category_by_cid(p_cid int)`
|
||||
- `get_brand_by_cid(p_cid int)`
|
||||
- `get_shop_by_cid(p_cid int)`
|
||||
- `generate_seo_url(p_type, p_cid, p_slug)`
|
||||
- `update_seo_slugs()`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
select * from public.get_category_by_cid(1001);
|
||||
select public.generate_seo_url('shop', 88, 'my-shop');
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- `update_seo_slugs()` 使用正则把名称转为 slug:
|
||||
- 适合初始化/批处理
|
||||
- 需要注意多语言、重复 slug、空字符等边界
|
||||
159
docs/sql/05_rls_permissions_matrix.md
Normal file
159
docs/sql/05_rls_permissions_matrix.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 05 RLS 权限矩阵(Supabase 行级安全)
|
||||
|
||||
本节整理 `complete_mall_database.sql` 中的 RLS(Row Level Security)启用范围与策略意图,并给出“角色 × 表 × 操作”的矩阵化视角,便于前后端对齐。
|
||||
|
||||
> 说明:该库采用 Supabase 模式,常用 `auth.uid()` 获取当前登录用户的 auth id,并通过 `ak_users.auth_id` 映射到业务用户 `ak_users.id`。
|
||||
|
||||
---
|
||||
|
||||
## 1. RLS 设计目标
|
||||
|
||||
- **默认拒绝**:启用 RLS 后,如果没有策略,访问会被拒绝。
|
||||
- **数据隔离优先**:用户私有数据只能访问自己的行。
|
||||
- **商家/用户双视角**:订单可被“买家”和“卖家”访问。
|
||||
- **公共可见数据受限**:商品仅公开上架数据。
|
||||
|
||||
---
|
||||
|
||||
## 2. 启用 RLS 的表(来自 `complete_mall_database.sql`)
|
||||
|
||||
脚本显式启用 RLS:
|
||||
|
||||
- `ml_user_profiles`
|
||||
- `ml_user_addresses`
|
||||
- `ml_shopping_cart`
|
||||
- `ml_user_favorites`
|
||||
- `ml_browse_history`
|
||||
- `ml_user_coupons`
|
||||
- `ml_orders`
|
||||
- `ml_products`
|
||||
|
||||
> 备注:其他表(如 `ml_categories/ml_brands/ml_shops/ml_order_items` 等)在该脚本片段中未显式启用 RLS。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心策略模式(pattern)
|
||||
|
||||
### 3.1 “归属自己”的通用模式
|
||||
|
||||
对用户私有表(档案、地址、购物车、收藏、浏览、券)使用类似逻辑:
|
||||
|
||||
- `SELECT/UPDATE/DELETE`:要求当前 `auth.uid()` 对应到该行的 `user_id`
|
||||
- `INSERT`:要求插入行的 `user_id` 也属于当前 `auth.uid()`
|
||||
|
||||
概念表达(伪 SQL):
|
||||
|
||||
```sql
|
||||
-- 伪表达:当前登录者只能操作 user_id 属于自己的行
|
||||
auth.uid() = (select auth_id from ak_users where id = <row.user_id>)
|
||||
```
|
||||
|
||||
价值:
|
||||
|
||||
- 前端直连 DB 时,**就算请求参数伪造 user_id**,也无法读写别人的行。
|
||||
|
||||
### 3.2 “订单:买家/卖家都可访问”模式
|
||||
|
||||
订单 SELECT 策略允许 `auth.uid()` 属于 `user_id` 或 `merchant_id`:
|
||||
|
||||
```sql
|
||||
auth.uid() in (
|
||||
select auth_id from ak_users where id in (user_id, merchant_id)
|
||||
)
|
||||
```
|
||||
|
||||
价值:
|
||||
|
||||
- 买家能看自己的订单
|
||||
- 商家能看自己店铺相关订单(在当前“单商家订单模型”下成立)
|
||||
|
||||
### 3.3 “商品:公开上架,商家管理自己的”模式
|
||||
|
||||
- `SELECT`:仅 `status = 1` 的商品可见
|
||||
- `INSERT/UPDATE/DELETE`:要求 `merchant_id` 属于当前登录商家
|
||||
|
||||
---
|
||||
|
||||
## 4. 权限矩阵(建议口径)
|
||||
|
||||
> 说明:此矩阵从业务语义出发描述“期望权限”。实际是否满足,还取决于:
|
||||
> - 是否启用 RLS
|
||||
> - 是否存在相应策略
|
||||
> - `ak_users` 中角色定义与 `auth_id` 映射是否正确
|
||||
|
||||
### 4.1 角色定义
|
||||
|
||||
- **Customer(消费者)**:普通用户
|
||||
- **Merchant(商家)**:拥有商品与订单管理权限
|
||||
- **Admin(管理员)**:平台管理(通常需要 service role 或额外策略)
|
||||
|
||||
### 4.2 表级矩阵(读/写)
|
||||
|
||||
#### `ml_user_profiles`
|
||||
|
||||
- Customer
|
||||
- **SELECT**:仅本人
|
||||
- **INSERT/UPDATE/DELETE**:仅本人
|
||||
- Merchant
|
||||
- 同 Customer(如果商家也是用户)
|
||||
- Admin
|
||||
- 建议:通过 service role 或单独策略可读全量
|
||||
|
||||
#### `ml_user_addresses`
|
||||
|
||||
- Customer
|
||||
- **SELECT/INSERT/UPDATE/DELETE**:仅本人
|
||||
|
||||
#### `ml_shopping_cart`
|
||||
|
||||
- Customer
|
||||
- **SELECT/INSERT/UPDATE/DELETE**:仅本人
|
||||
|
||||
#### `ml_user_favorites` / `ml_browse_history` / `ml_user_coupons`
|
||||
|
||||
- Customer
|
||||
- **SELECT/INSERT/UPDATE/DELETE**:仅本人
|
||||
|
||||
#### `ml_orders`
|
||||
|
||||
- Customer
|
||||
- **SELECT/INSERT/UPDATE/DELETE**:仅自己的订单
|
||||
- Merchant
|
||||
- **SELECT/INSERT/UPDATE/DELETE**:仅 `merchant_id` 为自己的订单
|
||||
- Admin
|
||||
- 建议:service role 或独立策略全量访问
|
||||
|
||||
#### `ml_products`
|
||||
|
||||
- Public / Customer
|
||||
- **SELECT**:仅上架(`status=1`)
|
||||
- Merchant
|
||||
- **SELECT**:至少能看上架;更合理的做法是:商家能看自己所有状态商品(当前策略是否支持需核对)
|
||||
- **INSERT/UPDATE/DELETE**:仅自己的商品
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键前提与性能建议
|
||||
|
||||
### 5.1 `ak_users.auth_id` 的唯一性与索引
|
||||
|
||||
由于策略频繁执行子查询:
|
||||
|
||||
```sql
|
||||
select auth_id from ak_users where id = ...
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 确保 `ak_users.id` 为主键(已有)
|
||||
- 确保 `ak_users.auth_id` 存在且唯一(建议唯一索引)
|
||||
|
||||
### 5.2 RLS 子查询的成本
|
||||
|
||||
RLS 每次查询都要执行策略表达式。若策略中大量子查询,可能带来性能压力。
|
||||
|
||||
可选优化方向:
|
||||
|
||||
- 在业务表冗余 `auth_id`(空间换性能)
|
||||
- 使用 `security definer` 函数封装策略逻辑(需谨慎)
|
||||
- 确保常用过滤字段(`user_id/merchant_id/status`)有索引
|
||||
179
docs/sql/06_indexes_and_query_patterns.md
Normal file
179
docs/sql/06_indexes_and_query_patterns.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 06 索引策略与典型查询模式
|
||||
|
||||
本节从“页面/接口会怎么查”出发解释索引的设计意图,并给出可复用的查询模式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 索引总体思路
|
||||
|
||||
从 `complete_mall_database.sql` / `mall_migration.sql` 中可以看到索引集中在:
|
||||
|
||||
- 列表页高频过滤字段:`status`、`created_at`、`merchant_id`、`user_id`、`category_id`
|
||||
- 对外访问字段:`cid`、`slug`
|
||||
- 排序/榜单字段:`sale_count`、`rating_avg`、`rating_count`、`base_price`
|
||||
- 多值字段:`tags`(GIN)
|
||||
|
||||
其核心理念是:
|
||||
|
||||
- **读路径优先**:电商最常见的是“列表页 + 详情页”,索引优先覆盖这些路径。
|
||||
- **SEO 友好**:对外 URL 常用 `cid/slug`,因此为其建索引。
|
||||
- **避免重计算**:用触发器维护汇总字段(库存/销量),让查询尽量落在单表或轻量 join。
|
||||
|
||||
---
|
||||
|
||||
## 2. 典型查询模式与对应索引
|
||||
|
||||
> 注:以下 SQL 示例以可读性为主,实际项目可能通过视图(如 `ml_products_detail_view`)或 API 层封装。
|
||||
|
||||
### 2.1 商品列表页(按分类 + 上架状态 + 时间倒序)
|
||||
|
||||
典型查询:
|
||||
|
||||
```sql
|
||||
select id, cid, name, base_price, main_image_url, sale_count, rating_avg
|
||||
from public.ml_products
|
||||
where category_id = '...category_uuid...'::uuid
|
||||
and status = 1
|
||||
order by created_at desc
|
||||
limit 20 offset 0;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_products_category(category_id, status)`
|
||||
- `idx_ml_products_status(status, created_at desc)`
|
||||
|
||||
### 2.2 商品列表页(商家后台:按商家 + 状态)
|
||||
|
||||
```sql
|
||||
select id, cid, name, status, total_stock, sale_count
|
||||
from public.ml_products
|
||||
where merchant_id = '...merchant_uuid...'::uuid
|
||||
order by updated_at desc
|
||||
limit 50;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_products_merchant(merchant_id, status)`(也会被 merchant_id 过滤利用)
|
||||
|
||||
### 2.3 商品详情页(按 cid 或 slug)
|
||||
|
||||
```sql
|
||||
-- 方式 1:cid
|
||||
select * from public.get_product_by_cid(12345);
|
||||
|
||||
-- 方式 2:slug
|
||||
select *
|
||||
from public.ml_products
|
||||
where slug = 'iphone-15-pro' and status = 1;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_products_cid(cid)`
|
||||
- `idx_ml_products_slug(slug)`
|
||||
|
||||
### 2.4 商品搜索/筛选(按 tags)
|
||||
|
||||
```sql
|
||||
select id, cid, name
|
||||
from public.ml_products
|
||||
where status = 1
|
||||
and tags @> array['手机','苹果']::text[]
|
||||
order by sale_count desc
|
||||
limit 20;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_products_tags using gin(tags)`
|
||||
|
||||
说明:
|
||||
|
||||
- `tags @> array[...]` 是典型的 GIN 可加速模式。
|
||||
|
||||
### 2.5 订单列表(用户维度)
|
||||
|
||||
```sql
|
||||
select id, order_no, total_amount, order_status, created_at
|
||||
from public.ml_orders
|
||||
where user_id = '...user_uuid...'::uuid
|
||||
order by created_at desc
|
||||
limit 20;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_orders_user(user_id, created_at desc)`
|
||||
|
||||
### 2.6 订单列表(商家维度)
|
||||
|
||||
```sql
|
||||
select id, order_no, total_amount, order_status, created_at
|
||||
from public.ml_orders
|
||||
where merchant_id = '...merchant_uuid...'::uuid
|
||||
order by created_at desc
|
||||
limit 20;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_orders_merchant(merchant_id, created_at desc)`
|
||||
|
||||
### 2.7 订单按状态过滤(运营/商家后台常见)
|
||||
|
||||
```sql
|
||||
select id, order_no
|
||||
from public.ml_orders
|
||||
where order_status in (1,2,3)
|
||||
order by created_at desc
|
||||
limit 50;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_orders_status(order_status, created_at desc)`
|
||||
|
||||
### 2.8 购物车加载
|
||||
|
||||
```sql
|
||||
select c.*, s.price, p.name
|
||||
from public.ml_shopping_cart c
|
||||
left join public.ml_product_skus s on s.id = c.sku_id
|
||||
left join public.ml_products p on p.id = c.product_id
|
||||
where c.user_id = '...user_uuid...'::uuid
|
||||
order by c.updated_at desc;
|
||||
```
|
||||
|
||||
依赖索引:
|
||||
|
||||
- `idx_ml_shopping_cart_user(user_id)`
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 字段的索引缺口(建议项)
|
||||
|
||||
当前脚本对 `tags` 做了 GIN,但对以下 JSONB 的查询与索引没有“强约束”体现:
|
||||
|
||||
- `ml_orders.shipping_address`
|
||||
- `ml_shops.address/business_hours`
|
||||
- `ml_coupon_templates.applicable_products/categories`
|
||||
|
||||
如果业务上出现以下高频查询:
|
||||
|
||||
- “按城市/区域筛选订单/店铺”
|
||||
- “某个商品可用哪些券”
|
||||
|
||||
建议考虑:
|
||||
|
||||
- 关系化建模(反向关联表)
|
||||
- 或表达式索引(例如对 JSONB 内部字段建索引)
|
||||
|
||||
---
|
||||
|
||||
## 4. 索引维护建议
|
||||
|
||||
- 新增字段/查询前先用 `EXPLAIN (ANALYZE, BUFFERS)` 验证是否命中索引。
|
||||
- 避免为低选择性字段(如 `status` 单列)盲目建索引,优先组合索引匹配真实查询。
|
||||
- 注意 RLS 会影响执行计划与开销,常用过滤字段建议都具备索引(`user_id/merchant_id/status/created_at`)。
|
||||
333
docs/sql/07_business_workflows.md
Normal file
333
docs/sql/07_business_workflows.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 07 典型业务流程:落表路径与关键字段
|
||||
|
||||
本节用“业务步骤 → 涉及表 → 关键字段/约束/触发器”的方式,把核心链路讲清楚,方便新同事快速理解数据如何流动。
|
||||
|
||||
---
|
||||
|
||||
## 1. 商品发布与上架流程(商家侧)
|
||||
|
||||
### 1.1 创建 SPU(商品主表)
|
||||
|
||||
- **写入表**:`ml_products`
|
||||
- **关键字段**:
|
||||
- `merchant_id`:商家用户(关联 `ak_users.id`)
|
||||
- `category_id/brand_id`
|
||||
- `base_price`
|
||||
- `status`:初始可为草稿(3)或上架(1)
|
||||
- `cid/slug`:对外访问
|
||||
|
||||
示例(简化):
|
||||
|
||||
```sql
|
||||
insert into public.ml_products(
|
||||
merchant_id, category_id, product_code, name, base_price, status
|
||||
)
|
||||
values (
|
||||
'...merchant_uuid...'::uuid,
|
||||
'...category_uuid...'::uuid,
|
||||
'P20260001',
|
||||
'苹果手机',
|
||||
4999.00,
|
||||
3
|
||||
)
|
||||
returning id, cid;
|
||||
```
|
||||
|
||||
### 1.2 定义规格项(可选)
|
||||
|
||||
- **写入表**:`ml_product_specs`
|
||||
- **关键字段**:`spec_name`、`spec_values JSONB`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
insert into public.ml_product_specs(product_id, spec_name, spec_values)
|
||||
values
|
||||
('...product_uuid...'::uuid, '颜色', '["黑","白"]'::jsonb),
|
||||
('...product_uuid...'::uuid, '容量', '["128G","256G"]'::jsonb);
|
||||
```
|
||||
|
||||
### 1.3 创建 SKU(库存与具体价格)
|
||||
|
||||
- **写入表**:`ml_product_skus`
|
||||
- **关键字段**:`specifications JSONB`、`price`、`stock`、`status`
|
||||
- **数据库规则**:
|
||||
- SKU 变更会触发 `update_product_stock()`,自动汇总刷新 `ml_products.total_stock/available_stock`
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
insert into public.ml_product_skus(product_id, sku_code, specifications, price, stock)
|
||||
values
|
||||
(
|
||||
'...product_uuid...'::uuid,
|
||||
'SKU-001',
|
||||
'{"颜色":"黑","容量":"128G"}'::jsonb,
|
||||
4999.00,
|
||||
10
|
||||
);
|
||||
|
||||
-- 插入 SKU 后,触发器会把商品 total_stock/available_stock 更新为 10
|
||||
```
|
||||
|
||||
### 1.4 上架商品
|
||||
|
||||
- **更新表**:`ml_products`
|
||||
- **关键字段**:`status = 1`、`published_at`(若使用)
|
||||
|
||||
示例:
|
||||
|
||||
```sql
|
||||
update public.ml_products
|
||||
set status = 1, published_at = now()
|
||||
where id = '...product_uuid...'::uuid;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 浏览与收藏(用户侧)
|
||||
|
||||
### 2.1 浏览记录
|
||||
|
||||
- **写入表**:`ml_browse_history`
|
||||
- **约束**:`UNIQUE(user_id, product_id)`
|
||||
- **含义**:倾向记录“最后一次浏览”而不是“浏览流水”。
|
||||
|
||||
典型写法:
|
||||
|
||||
- 插入失败后转更新(upsert)
|
||||
|
||||
```sql
|
||||
insert into public.ml_browse_history(user_id, product_id, browse_duration)
|
||||
values ('...user_uuid...'::uuid, '...product_uuid...'::uuid, 20)
|
||||
on conflict (user_id, product_id)
|
||||
do update set browse_duration = excluded.browse_duration, updated_at = now();
|
||||
```
|
||||
|
||||
### 2.2 收藏
|
||||
|
||||
- **写入表**:`ml_user_favorites`
|
||||
- **约束**:`UNIQUE(user_id, target_type, target_id)`
|
||||
|
||||
```sql
|
||||
insert into public.ml_user_favorites(user_id, target_type, target_id)
|
||||
values ('...user_uuid...'::uuid, 1, '...product_uuid...'::uuid)
|
||||
on conflict do nothing;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 加购与结算(用户侧)
|
||||
|
||||
### 3.1 加入购物车
|
||||
|
||||
- **写入表**:`ml_shopping_cart`
|
||||
- **约束**:`UNIQUE(user_id, product_id, sku_id)`
|
||||
|
||||
常见做法:重复加购时做累加:
|
||||
|
||||
```sql
|
||||
insert into public.ml_shopping_cart(user_id, product_id, sku_id, quantity, selected)
|
||||
values ('...user_uuid...'::uuid, '...product_uuid...'::uuid, '...sku_uuid...'::uuid, 1, true)
|
||||
on conflict (user_id, product_id, sku_id)
|
||||
do update set quantity = public.ml_shopping_cart.quantity + 1, updated_at = now();
|
||||
```
|
||||
|
||||
### 3.2 计算购物车金额
|
||||
|
||||
- **读取函数**:`public.calculate_cart_total(p_user_id)`
|
||||
|
||||
```sql
|
||||
select public.calculate_cart_total('...user_uuid...'::uuid);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 下单(创建订单 + 明细快照)
|
||||
|
||||
### 4.1 订单号生成
|
||||
|
||||
- **函数**:`public.generate_order_no()`(基于 `ml_order_seq`)
|
||||
|
||||
```sql
|
||||
select public.generate_order_no();
|
||||
```
|
||||
|
||||
### 4.2 创建订单主表(地址快照)
|
||||
|
||||
- **写入表**:`ml_orders`
|
||||
- **关键字段**:
|
||||
- `shipping_address JSONB`:下单时把地址“快照化”写进订单
|
||||
- `order_status/payment_status/shipping_status`
|
||||
|
||||
示例(简化):
|
||||
|
||||
```sql
|
||||
insert into public.ml_orders(
|
||||
order_no, user_id, merchant_id,
|
||||
product_amount, discount_amount, shipping_fee, total_amount,
|
||||
shipping_address,
|
||||
order_status, payment_status, shipping_status
|
||||
)
|
||||
values (
|
||||
public.generate_order_no(),
|
||||
'...user_uuid...'::uuid,
|
||||
'...merchant_uuid...'::uuid,
|
||||
4999.00, 0.00, 10.00, 5009.00,
|
||||
'{"receiver":"张三","phone":"138...","province":"广东","city":"深圳","district":"南山","detail":"xxx"}'::jsonb,
|
||||
1, 1, 1
|
||||
)
|
||||
returning id;
|
||||
```
|
||||
|
||||
### 4.3 创建订单明细(商品快照)
|
||||
|
||||
- **写入表**:`ml_order_items`
|
||||
- **关键点**:把 `product_name/sku_name/specifications/image_url/price` 等写入明细,防止商品后改影响历史。
|
||||
|
||||
```sql
|
||||
insert into public.ml_order_items(
|
||||
order_id, product_id, sku_id,
|
||||
product_name, sku_name, specifications, image_url,
|
||||
price, quantity, total_amount
|
||||
)
|
||||
values (
|
||||
'...order_uuid...'::uuid,
|
||||
'...product_uuid...'::uuid,
|
||||
'...sku_uuid...'::uuid,
|
||||
'苹果手机',
|
||||
'黑/128G',
|
||||
'{"颜色":"黑","容量":"128G"}'::jsonb,
|
||||
'https://.../1.png',
|
||||
4999.00,
|
||||
1,
|
||||
4999.00
|
||||
);
|
||||
```
|
||||
|
||||
> 注意:当前可见 SQL 未体现“扣库存/冻结库存”动作,通常需要在同一事务中由应用层或额外 DB 函数完成(详见 `08_data_consistency_boundaries.md`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 支付、发货、完成(状态流转)
|
||||
|
||||
### 5.1 支付完成
|
||||
|
||||
- **更新表**:`ml_orders`
|
||||
- **预期**:`order_status: 1 -> 2`,并写 `paid_amount/payment_status`
|
||||
- **数据库副作用(complete 脚本)**:触发器 `handle_order_status_change()` 自动写 `paid_at`
|
||||
|
||||
```sql
|
||||
update public.ml_orders
|
||||
set order_status = 2,
|
||||
payment_status = 2,
|
||||
paid_amount = total_amount
|
||||
where id = '...order_uuid...'::uuid;
|
||||
```
|
||||
|
||||
### 5.2 发货
|
||||
|
||||
```sql
|
||||
update public.ml_orders
|
||||
set order_status = 3,
|
||||
shipping_status = 2
|
||||
where id = '...order_uuid...'::uuid;
|
||||
|
||||
-- complete 脚本的触发器会在 2->3 时写 shipped_at
|
||||
```
|
||||
|
||||
### 5.3 收货完成
|
||||
|
||||
```sql
|
||||
update public.ml_orders
|
||||
set order_status = 4,
|
||||
shipping_status = 4
|
||||
where id = '...order_uuid...'::uuid;
|
||||
|
||||
-- complete 脚本触发器在 3->4 时写 delivered_at/completed_at,并累计商品 sale_count
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 评价
|
||||
|
||||
- **写入表**:`ml_product_reviews`
|
||||
- **约束设计**:强绑定 `order_id` 与 `order_item_id`,保证评价来自真实订单。
|
||||
|
||||
```sql
|
||||
insert into public.ml_product_reviews(
|
||||
order_id, order_item_id, user_id, product_id, merchant_id,
|
||||
rating, content, images
|
||||
)
|
||||
values (
|
||||
'...order_uuid...'::uuid,
|
||||
'...order_item_uuid...'::uuid,
|
||||
'...user_uuid...'::uuid,
|
||||
'...product_uuid...'::uuid,
|
||||
'...merchant_uuid...'::uuid,
|
||||
5,
|
||||
'很好用',
|
||||
'["https://.../a.png"]'::jsonb
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 优惠券:发放与使用
|
||||
|
||||
### 7.1 领券
|
||||
|
||||
- **写入表**:`ml_user_coupons`
|
||||
- **券码**:可使用 `generate_coupon_code()` 生成
|
||||
|
||||
```sql
|
||||
insert into public.ml_user_coupons(user_id, template_id, coupon_code, status, expire_at)
|
||||
values (
|
||||
'...user_uuid...'::uuid,
|
||||
'...template_uuid...'::uuid,
|
||||
public.generate_coupon_code(),
|
||||
1,
|
||||
now() + interval '30 days'
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 用券归因
|
||||
|
||||
```sql
|
||||
update public.ml_user_coupons
|
||||
set status = 2,
|
||||
used_at = now(),
|
||||
order_id = '...order_uuid...'::uuid
|
||||
where id = '...user_coupon_uuid...'::uuid
|
||||
and status = 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 订阅:开通/续费/到期
|
||||
|
||||
### 8.1 创建套餐
|
||||
|
||||
- `ml_subscription_plans`
|
||||
|
||||
```sql
|
||||
insert into public.ml_subscription_plans(plan_code, name, price, billing_period)
|
||||
values ('PRO_MONTH', '专业版(月付)', 99.00, 'monthly');
|
||||
```
|
||||
|
||||
### 8.2 用户订阅
|
||||
|
||||
- `ml_user_subscriptions`
|
||||
|
||||
```sql
|
||||
insert into public.ml_user_subscriptions(user_id, plan_id, status, start_date, next_billing_date)
|
||||
values (
|
||||
'...user_uuid...'::uuid,
|
||||
'...plan_uuid...'::uuid,
|
||||
'trial',
|
||||
now(),
|
||||
now() + interval '30 days'
|
||||
);
|
||||
```
|
||||
|
||||
> 说明:订阅模块脚本未与 `ml_orders` 建立外键关联,支付/对账链路通常在应用层或另一个交易子系统实现。
|
||||
116
docs/sql/08_data_consistency_boundaries.md
Normal file
116
docs/sql/08_data_consistency_boundaries.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 08 数据一致性边界:数据库保证什么?应用层还需要保证什么?
|
||||
|
||||
本节的目的:把“责任边界”讲清楚,避免团队误以为数据库已经覆盖了所有一致性问题。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据库层已经显式保证的内容(来自脚本)
|
||||
|
||||
### 1.1 约束(Constraints)保证
|
||||
|
||||
- **枚举合法性**:大量使用 `CHECK (status IN (...))`
|
||||
- 例:`ml_products.status`、`ml_orders.order_status/payment_status/shipping_status`
|
||||
- **唯一性**:
|
||||
- `order_no UNIQUE`
|
||||
- `coupon_code UNIQUE`
|
||||
- `ml_shopping_cart UNIQUE(user_id, product_id, sku_id)`
|
||||
- `ml_shops.merchant_id UNIQUE`(一商家一店)
|
||||
- `ml_delivery_tasks.order_id UNIQUE`(一订单一配送任务)
|
||||
|
||||
### 1.2 触发器保证
|
||||
|
||||
- **`updated_at` 自动维护**:避免应用层漏写
|
||||
- **默认地址唯一**:`ensure_single_default_address()`
|
||||
- **SKU → SPU 库存汇总**:`update_product_stock()`
|
||||
- **订单状态副作用(complete 脚本)**:`handle_order_status_change()` 自动写时间戳,并累计销量
|
||||
|
||||
### 1.3 RLS(行级安全)保证
|
||||
|
||||
- 用户私有数据仅可访问自己的行(档案/地址/购物车/收藏/浏览/券等)
|
||||
- 订单允许买家/卖家访问
|
||||
- 商品公开查询仅限上架
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库层“目前未完全覆盖”的一致性问题(需要显式补齐)
|
||||
|
||||
> 以下并不意味着当前设计不好,而是典型电商系统里必须明确“谁来保证”。
|
||||
|
||||
### 2.1 下单扣库存、防超卖
|
||||
|
||||
脚本可见“库存汇总”,但没有看到:
|
||||
|
||||
- 下单时对 `ml_product_skus.stock` 的原子扣减
|
||||
- 订单取消/超时未支付的库存回补
|
||||
- 库存冻结(预占)机制
|
||||
|
||||
**风险**:并发下单可能超卖。
|
||||
|
||||
**建议补齐方案**(按复杂度递增):
|
||||
|
||||
- **方案 A(最小补齐)**:提供一个原子扣减函数
|
||||
- `update ml_product_skus set stock = stock - :qty where id=:sku_id and stock >= :qty;`
|
||||
- 受影响行数为 1 表示扣减成功,否则失败
|
||||
- **方案 B(常见电商)**:引入“冻结库存”
|
||||
- SKU 增加 `reserved_stock` 或独立冻结表
|
||||
- 下单冻结、支付确认扣减、取消释放
|
||||
- **方案 C(审计与对账)**:引入库存流水
|
||||
- 每次扣减/回补记录流水,便于审计
|
||||
|
||||
### 2.2 支付对账与幂等
|
||||
|
||||
脚本中订单有 `payment_status/paid_amount`,但未见:
|
||||
|
||||
- 支付流水表(第三方支付回调存证)
|
||||
- 支付回调幂等键
|
||||
- 退款流水/退款幂等
|
||||
|
||||
**建议**:补交易流水表(或在应用层引入专门支付子系统)并明确幂等策略。
|
||||
|
||||
### 2.3 优惠券核销一致性
|
||||
|
||||
当前有 `ml_user_coupons.status/used_at/order_id`,但未见:
|
||||
|
||||
- “同一张券只能用一次”的强事务保证(除非应用层做 CAS 更新)
|
||||
- 与订单金额计算的强一致校验
|
||||
|
||||
建议:
|
||||
|
||||
- 用条件更新核销:`where status=1` 确保并发只成功一次
|
||||
- 关键核销与订单创建在同一事务内
|
||||
|
||||
### 2.4 统计字段回滚
|
||||
|
||||
脚本在订单完成时累计 `sale_count`,但未看到:
|
||||
|
||||
- 退款/取消是否回滚 `sale_count`
|
||||
|
||||
建议:
|
||||
|
||||
- 明确统计字段口径:
|
||||
- `sale_count` 是“累计成交量”还是“累计下单量”
|
||||
- 若需可回滚:要补对应触发器/作业,或使用流水聚合。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建议的边界划分(团队共识)
|
||||
|
||||
- **数据库层**:
|
||||
- 基础合法性(约束/唯一性)
|
||||
- 关键自动维护字段(updated_at、默认地址唯一、库存汇总、SEO 等)
|
||||
- 访问控制(RLS)
|
||||
- **应用层**:
|
||||
- 复杂事务(下单扣库存、支付幂等、退款)
|
||||
- 业务规则组合(优惠叠加、分摊、拆单、风控)
|
||||
- 跨域协调(订阅与订单的统一计费/对账)
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐补充的“最小一致性清单”(可用于评审)
|
||||
|
||||
- 下单扣减库存是否原子?
|
||||
- 未支付关闭订单是否回补库存?
|
||||
- 支付回调是否幂等?
|
||||
- 退款回调是否幂等?
|
||||
- 优惠券核销是否并发安全?
|
||||
- 统计字段口径是否明确、是否需要回滚?
|
||||
126
docs/sql/09_migrations_and_versions.md
Normal file
126
docs/sql/09_migrations_and_versions.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 09 迁移与版本策略(complete vs migration vs tests)
|
||||
|
||||
本节解释仓库中与商城数据库相关的脚本如何分工、适合在什么场景使用,以及推荐的执行顺序。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你现在有哪几类 SQL
|
||||
|
||||
### 1.1 “完整初始化脚本”(偏一次性创建)
|
||||
|
||||
- `doc_mall/database/complete_mall_database.sql`
|
||||
|
||||
特点:
|
||||
|
||||
- 覆盖范围大:表结构、索引、触发器、函数、视图、RLS 策略、初始化数据、SEO 函数等。
|
||||
- 更像“新环境一键搭建”的脚本。
|
||||
|
||||
风险点:
|
||||
|
||||
- 若在已有环境重复执行,可能因为对象已存在/差异存在导致失败(脚本虽有部分 `IF NOT EXISTS`,但不是全幂等)。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 新环境快速搭建
|
||||
- 演示/验证/PoC
|
||||
|
||||
### 1.2 “迁移脚本”(偏幂等/可增量)
|
||||
|
||||
- `doc_mall/database/mall_migration.sql`
|
||||
|
||||
特点:
|
||||
|
||||
- 大量使用 `CREATE TABLE IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS`。
|
||||
- 触发器创建使用 `DO $$ ... IF NOT EXISTS` 的方式避免重复创建报错。
|
||||
- 插入初始化数据使用 `ON CONFLICT DO NOTHING`。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 在已有数据库上“补齐商城模块”
|
||||
- 生产环境更安全的增量迁移
|
||||
|
||||
### 1.3 “升级/差异修复脚本”(按数据库现状推荐执行)
|
||||
|
||||
从 `mall_sql/tests/mall_database_check.sql` 可看出项目侧存在“根据现状推荐脚本”的思路,提到:
|
||||
|
||||
- `mall_alter_upgrade.sql`:完整升级(表 + 字段 + 索引 + 函数)
|
||||
- `mall_fields_only_upgrade.sql`:仅字段升级(最小化修改)
|
||||
- `mall_migration.sql`:完整建表(全新部署/缺表时)
|
||||
- `mall_seo_security.sql`:SEO 优化与安全策略
|
||||
|
||||
> 这些脚本在 `doc_mall/database/` 与 `mall_sql/migrations/` 中都有对应版本(具体以仓库实际文件为准)。
|
||||
|
||||
### 1.4 “检查/测试脚本”(偏验收与自检)
|
||||
|
||||
- `mall_sql/tests/mall_database_check.sql`
|
||||
- `mall_sql/tests/validation_test.sql`
|
||||
- `mall_sql/tests/mock_data_insert.sql`
|
||||
- `mall_sql/tests/verify_mock_data_fix.sql`
|
||||
- `mall_sql/tests/create_supabase_auth_users.sql`
|
||||
|
||||
适用场景:
|
||||
|
||||
- 验收环境是否缺表/缺字段/缺索引
|
||||
- 生成建议与 ALTER 语句
|
||||
- 插入 mock 数据用于联调
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐执行顺序(生产/测试通用)
|
||||
|
||||
### 2.1 新环境(从 0 到可用)
|
||||
|
||||
推荐路径(更稳妥):
|
||||
|
||||
1. 执行 `mall_migration.sql`(幂等建表 + 基础索引 + 关键函数/触发器)
|
||||
2. 执行 SEO 与安全策略脚本(如 `mall_seo_security.sql`,若 migration 未覆盖)
|
||||
3. 执行订阅模块脚本(如需要):`create_mall_subscription_tables.sql`
|
||||
4. 执行检查脚本:`mall_database_check.sql`
|
||||
5.(测试环境)执行 mock 数据脚本:`mock_data_insert.sql`
|
||||
|
||||
替代路径(快速但风险更高):
|
||||
|
||||
- 直接执行 `complete_mall_database.sql` 一次性完成(适合演示/PoC)。
|
||||
|
||||
### 2.2 已有环境(补齐缺失)
|
||||
|
||||
1. 先执行检查脚本:`mall_database_check.sql`
|
||||
2. 根据输出建议选择:
|
||||
- 缺字段多 + 缺表:`mall_alter_upgrade.sql`
|
||||
- 只缺字段:`mall_fields_only_upgrade.sql`
|
||||
- 只缺表:`mall_migration.sql`
|
||||
3. 再执行 SEO/RLS 策略脚本(如缺失)
|
||||
4. 再跑一次检查脚本确认
|
||||
|
||||
---
|
||||
|
||||
## 3. 版本差异与兼容性注意点
|
||||
|
||||
### 3.1 订单状态口径差异
|
||||
|
||||
在不同脚本中对 `order_status = 5` 的文字描述存在差异(“取消/取货”)。
|
||||
|
||||
建议:
|
||||
|
||||
- 在应用层/文档中统一口径
|
||||
- 如需扩展状态,务必迁移更新 `CHECK` 约束与相关触发器逻辑
|
||||
|
||||
### 3.2 用户角色字段差异:`ak_users.role` vs `ml_user_profiles.user_type`
|
||||
|
||||
- `mall_migration.sql`:在 `ml_user_profiles` 引入 `user_type`
|
||||
- `complete_mall_database.sql`:更多使用 `ak_users.role`(并在视图里做 role_name 映射)
|
||||
|
||||
建议:
|
||||
|
||||
- 明确项目最终“权威字段”是哪一个
|
||||
- 避免两个口径长期并存造成权限与业务判断分裂
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐的团队规范
|
||||
|
||||
- 生产环境:优先使用“幂等迁移脚本 + 小步变更”,避免一次性大脚本覆盖执行。
|
||||
- 每次变更:
|
||||
- 同步更新检查脚本/验收项
|
||||
- 明确回滚策略(尤其是约束、枚举、数据迁移)
|
||||
- 对外接口依赖的 `cid/slug`:变更需谨慎(可能影响 SEO 与路由)。
|
||||
91
docs/sql/10_quality_checks.md
Normal file
91
docs/sql/10_quality_checks.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 10 质量自检与验收(表/字段/索引/扩展/函数)
|
||||
|
||||
本节汇总仓库中已有的“数据库状态检查/验证脚本”,并给出在交付与升级时推荐的自检流程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现有自检脚本清单
|
||||
|
||||
在 `mall_sql/tests/` 目录下可见:
|
||||
|
||||
- `mall_database_check.sql`
|
||||
- 检查 `ak_users` 是否缺少商城字段
|
||||
- 检查商城核心表是否存在
|
||||
- 检查关键索引是否存在
|
||||
- 检查必要扩展是否安装
|
||||
- 检查关键函数是否存在
|
||||
- 输出“推荐执行方案”(fields_only / alter_upgrade / migration / seo_security 等)
|
||||
- 可生成具体 `ALTER TABLE` 建议
|
||||
|
||||
- `validation_test.sql`(存在即表示有更细校验,建议结合实际内容补充到本文档)
|
||||
- `mock_data_insert.sql` / `verify_mock_data_fix.sql`
|
||||
- 用于联调/验证查询与页面
|
||||
- `create_supabase_auth_users.sql`
|
||||
- 可能用于创建 Supabase auth 用户或测试映射
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐自检流程(交付/升级)
|
||||
|
||||
### 2.1 升级前(评估现状)
|
||||
|
||||
1. 执行 `mall_database_check.sql`
|
||||
2. 根据输出信息确认:
|
||||
- `ak_users` 缺哪些字段
|
||||
- `ml_` 核心表缺哪些
|
||||
- 关键索引是否齐全
|
||||
- 扩展是否安装
|
||||
- 函数是否缺失
|
||||
|
||||
### 2.2 执行升级/迁移
|
||||
|
||||
根据 `mall_database_check.sql` 给出的推荐:
|
||||
|
||||
- 缺字段多 + 缺表:`mall_alter_upgrade.sql`
|
||||
- 只缺字段:`mall_fields_only_upgrade.sql`
|
||||
- 只缺表:`mall_migration.sql`
|
||||
- SEO/RLS 缺失:`mall_seo_security.sql`
|
||||
|
||||
### 2.3 升级后(验证验收)
|
||||
|
||||
1. 再次执行 `mall_database_check.sql`,确认缺失项归零
|
||||
2.(测试环境)插入 mock 数据(若有脚本)
|
||||
3. 跑核心链路的“最小验收用例”(见下)
|
||||
|
||||
---
|
||||
|
||||
## 3. 最小验收用例(建议团队固化)
|
||||
|
||||
### 3.1 用户侧
|
||||
|
||||
- 能创建/更新 `ml_user_profiles`
|
||||
- 能新增地址并设置默认,确认默认地址唯一
|
||||
- 能加购、更新购物车数量
|
||||
|
||||
### 3.2 商品侧
|
||||
|
||||
- 能创建商品 + SKU
|
||||
- 更新 SKU 库存,确认 `ml_products.total_stock/available_stock` 自动刷新
|
||||
- 商品上架后,普通用户侧可查询到(RLS/策略允许)
|
||||
|
||||
### 3.3 订单侧
|
||||
|
||||
- 能创建订单 + 明细
|
||||
- 更新订单状态(1->2->3->4),确认时间戳与销量累计逻辑(若使用 complete 脚本触发器)
|
||||
|
||||
### 3.4 营销/优惠券
|
||||
|
||||
- 创建券模板
|
||||
- 领券生成券码
|
||||
- 核销券并绑定订单
|
||||
|
||||
---
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- 生产执行前务必在测试环境验证脚本兼容性与执行顺序。
|
||||
- 若启用 RLS,检查脚本/运维脚本需要使用 service role 或具有相应权限的连接方式,否则可能误判数据可见性。
|
||||
- 如新增状态枚举值,需同步更新:
|
||||
- `CHECK` 约束
|
||||
- 相关触发器/函数
|
||||
- 文档字典(`03_enums_status_dict.md`)
|
||||
184
docs/sql/11_roles_and_permissions_strategy.md
Normal file
184
docs/sql/11_roles_and_permissions_strategy.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 11 角色、权限与路由整合策略
|
||||
|
||||
本节提供一套完整的“角色定义 → RLS 策略 → 前端路由/跳转 → 业务流程”整合方案,旨在将数据库安全模型与项目实际开发无缝结合。
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色定义(权威口径)
|
||||
|
||||
为避免权限判断分裂,项目应统一使用 `public.ak_users.role` 作为唯一权威的角色字段。
|
||||
|
||||
### 1.1 推荐的角色枚举
|
||||
|
||||
- `customer`:消费者
|
||||
- `merchant`:商家
|
||||
- `delivery`:配送员
|
||||
- `service`:客服
|
||||
- `admin`:平台管理员
|
||||
- `analytics`:数据分析/运营角色
|
||||
|
||||
> **决策点**:
|
||||
> - `analytics` 角色是可选的。如果运营/分析师与 `admin` 权限边界不清,可以先统一为 `admin`。
|
||||
> - 但长远看,为“数据查看者”设定独立角色有利于最小权限原则。
|
||||
|
||||
### 1.2 如何在数据库中获取当前用户角色
|
||||
|
||||
通常通过一个函数实现,该函数内部使用 `auth.uid()`。
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.get_current_user_role()
|
||||
RETURNS TEXT
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT role FROM public.ak_users WHERE auth_id = auth.uid() LIMIT 1;
|
||||
$$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. RLS 策略与权限设计
|
||||
|
||||
### 2.1 权限分层(推荐)
|
||||
|
||||
- **A. Row Owner(行归属者)**
|
||||
- 用户只能访问自己的数据,如地址、购物车、收藏、个人订单。
|
||||
- RLS 策略核心:`auth.uid() = (SELECT auth_id FROM ak_users WHERE id = <row.user_id>)`
|
||||
|
||||
- **B. Business Owner(业务归属者)**
|
||||
- 商家只能访问自己店铺的数据,如商品、店铺订单。
|
||||
- RLS 策略核心:`auth.uid() = (SELECT auth_id FROM ak_users WHERE id = <row.merchant_id>)`
|
||||
|
||||
- **C. Privileged(特权角色)**
|
||||
- `admin/analytics` 角色需要访问全局数据,尤其是聚合统计。
|
||||
- **强烈建议**:不要为这些角色直接开放表的全局 `SELECT` 权限。
|
||||
|
||||
### 2.2 如何让 `admin/analytics` 安全地看全局数据?
|
||||
|
||||
**推荐方案:RPC + `SECURITY DEFINER`**
|
||||
|
||||
1. **维持表的严格 RLS**:确保 `customer/merchant` 无法越权。
|
||||
2. **Analytics 页面只调用 RPC**:例如 `rpc_analytics_*` 系列函数。
|
||||
3. **RPC 函数必须 `SECURITY DEFINER`**:使其以“函数所有者”(通常是 `postgres` 超级用户)的权限执行,从而绕过调用者的 RLS 限制。
|
||||
4. **RPC 函数内部必须做显式鉴权**:这是安全闭环的关键。
|
||||
|
||||
**RPC 鉴权模板**:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.rpc_analytics_sales_kpis(
|
||||
p_start_date DATE,
|
||||
p_end_date DATE
|
||||
)
|
||||
RETURNS TABLE (...)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER -- 以函数所有者权限执行
|
||||
SET search_path = public -- 显式设置 search_path,避免 search_path 攻击
|
||||
AS $$
|
||||
BEGIN
|
||||
-- 1. 在函数入口处做权限检查
|
||||
IF get_current_user_role() NOT IN ('admin', 'analytics') THEN
|
||||
RAISE EXCEPTION 'Permission denied: required role admin or analytics';
|
||||
END IF;
|
||||
|
||||
-- 2. 执行统计(因为是 SECURITY DEFINER,这里可以查到所有数据)
|
||||
RETURN QUERY
|
||||
WITH ...
|
||||
-- ... 统计逻辑 ...
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
> **现状风险**:当前 `rpc_analytics_*` 脚本未包含 `SECURITY DEFINER` 与内部鉴权。若直接部署,当 RLS 开启时,`admin/analytics` 调用会因权限不足而查不到数据。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端项目整合:路由守卫与业务流程
|
||||
|
||||
### 3.1 路由分组(按角色)
|
||||
|
||||
项目页面按角色划分,便于集中管理路由与权限。
|
||||
|
||||
- `/pages/mall/consumer/**`
|
||||
- `/pages/mall/merchant/**`
|
||||
- `/pages/mall/delivery/**`
|
||||
- `/pages/mall/admin/**`
|
||||
- `/pages/mall/analytics/**`
|
||||
|
||||
### 3.2 路由守卫(客户端鉴权)
|
||||
|
||||
在 `services/analytics/authGuard.uts`(或类似文件)中,应提供更精细的守卫函数。
|
||||
|
||||
**守卫函数建议**:
|
||||
|
||||
```typescript
|
||||
// services/auth/guard.uts (示例)
|
||||
import { getCurrentUser } from './user.uts' // 假设此函数能获取当前登录用户及其角色
|
||||
|
||||
// 1. 确保已登录
|
||||
export function ensureLoggedIn(options: { redirect?: string } = {}): boolean {
|
||||
const user = getCurrentUser();
|
||||
if (!user) {
|
||||
uni.navigateTo({ url: options.redirect ?? '/pages/user/login' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 确保具备指定角色之一
|
||||
export function ensureRole(allowedRoles: Array<string>, options: { toastTitle?: string } = {}): boolean {
|
||||
if (!ensureLoggedIn()) return false;
|
||||
|
||||
const user = getCurrentUser();
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
uni.showToast({ title: options.toastTitle ?? '无权访问', icon: 'none' });
|
||||
// 可选择返回上一页或跳转首页
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**在 Analytics 页面中使用**:
|
||||
|
||||
```typescript
|
||||
// pages/mall/analytics/index.uvue
|
||||
onLoad(() => {
|
||||
if (!ensureRole(['admin', 'analytics'], { toastTitle: '仅管理员可访问数据分析' })) {
|
||||
return;
|
||||
}
|
||||
initDashboard();
|
||||
});
|
||||
```
|
||||
|
||||
### 3.3 业务流程闭环(以 Analytics 首页为例)
|
||||
|
||||
1. **用户访问** `/pages/mall/analytics/index`。
|
||||
2. **前端守卫**:`onLoad` 中 `ensureRole(['admin', 'analytics'])` 执行:
|
||||
- 未登录 → 跳转登录页
|
||||
- 已登录但角色不符 → toast 提示 + 返回
|
||||
3. **调用 Service**:`dashboardService.uts` 的 `fetch...` 函数被调用。
|
||||
4. **执行 RPC**:`rpcOrNull('rpc_analytics_sales_kpis', ...)` 发起请求。
|
||||
5. **数据库鉴权**:`rpc_analytics_sales_kpis` 函数内部首先检查 `get_current_user_role()` 是否为 `admin/analytics`。
|
||||
- 权限不足 → `RAISE EXCEPTION`,前端收到错误。
|
||||
- 权限通过 → 执行统计。
|
||||
6. **数据返回**:前端拿到聚合数据并渲染。
|
||||
|
||||
这个流程实现了“前端快速失败 + 后端强制校验”的安全闭环。
|
||||
|
||||
---
|
||||
|
||||
## 4. 权限矩阵(总结)
|
||||
|
||||
| 角色 | `customer` | `merchant` | `admin/analytics` |
|
||||
| -------- | -------------------------------------------- | ---------------------------- | -------------------------------------- |
|
||||
| **可读** | 上架商品、自己的(订单/地址/购物车/收藏/券) | 自己的(商品/订单/店铺数据) | 全局聚合数据(通过 RPC) |
|
||||
| **可写** | 自己的(地址/购物车/收藏/订单创建) | 自己的(商品/发货/售后) | 通常不直接写业务表(通过后台管理功能) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 待办与实现建议
|
||||
|
||||
1. **统一角色字段**:在项目中明确 `ak_users.role` 为唯一权威,并提供获取当前用户角色的函数。
|
||||
2. **增强 RPC 安全性**:为所有 `rpc_analytics_*` 函数增加 `SECURITY DEFINER` 与内部权限检查。
|
||||
3. **实现前端路由守卫**:创建 `ensureRole` 函数,并在所有 `analytics` 子包页面中统一调用。
|
||||
17
docs/sql/README.md
Normal file
17
docs/sql/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# SQL 文档目录(商城)
|
||||
|
||||
本目录用于存放商城数据库(`doc_mall` / `mall_sql`)的**详尽说明文档**,由 `docs/sql_summary.md` 作为入口索引。
|
||||
|
||||
## 文档列表
|
||||
|
||||
- `00_overview.md`
|
||||
- `01_tables_catalog.md`
|
||||
- `02_relationships_er.md`
|
||||
- `03_enums_status_dict.md`
|
||||
- `04_triggers_and_functions.md`
|
||||
- `05_rls_permissions_matrix.md`
|
||||
- `06_indexes_and_query_patterns.md`
|
||||
- `07_business_workflows.md`
|
||||
- `08_data_consistency_boundaries.md`
|
||||
- `09_migrations_and_versions.md`
|
||||
- `10_quality_checks.md`
|
||||
90
docs/sql_summary.md
Normal file
90
docs/sql_summary.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 商城数据库设计文档总览
|
||||
|
||||
本文档是商城数据库的入口索引,提供各模块详细文档的链接与摘要。
|
||||
|
||||
## 核心文档
|
||||
|
||||
1. **数据库概览**
|
||||
- [00 概览](sql/00_overview.md): 数据库设计目标、核心概念与术语说明
|
||||
- [01 表清单](sql/01_tables_catalog.md): 按业务域划分的完整表结构说明
|
||||
- [02 实体关系](sql/02_relationships_er.md): 表间关系与ER图说明
|
||||
|
||||
2. **数据字典**
|
||||
- [03 状态字典](sql/03_enums_status_dict.md): 所有状态字段的枚举值与业务含义
|
||||
|
||||
3. **核心功能**
|
||||
- [04 触发器与函数](sql/04_triggers_and_functions.md): 数据库层实现的业务规则
|
||||
- [05 权限矩阵](sql/05_rls_permissions_matrix.md): Supabase RLS行级安全策略说明
|
||||
- [06 索引与查询](sql/06_indexes_and_query_patterns.md): 索引设计与典型查询模式
|
||||
|
||||
4. **业务流程**
|
||||
- [07 业务流](sql/07_business_workflows.md): 核心业务场景的完整数据流
|
||||
- [08 一致性边界](sql/08_data_consistency_boundaries.md): 数据库保证什么/应用层需要保证什么
|
||||
|
||||
5. **部署维护**
|
||||
- [09 迁移策略](sql/09_migrations_and_versions.md): 不同环境下的迁移指南
|
||||
- [10 质量检查](sql/10_quality_checks.md): 自检清单与验收标准
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 新环境初始化
|
||||
|
||||
```bash
|
||||
# 1. 执行基础迁移
|
||||
psql -f doc_mall/database/mall_migration.sql
|
||||
|
||||
# 2. 应用安全策略
|
||||
psql -f doc_mall/database/mall_seo_security.sql
|
||||
|
||||
# 3. 验证数据库状态
|
||||
psql -f mall_sql/tests/mall_database_check.sql
|
||||
```
|
||||
|
||||
### 核心表关系速查
|
||||
|
||||
- 用户: `ak_users` ↔ `ml_user_profiles` (1:1)
|
||||
- 商品: `ml_products` (SPU) ↔ `ml_product_skus` (1:N)
|
||||
- 订单: `ml_orders` ↔ `ml_order_items` (1:N)
|
||||
- 店铺: `ak_users` (merchant) ↔ `ml_shops` (1:1)
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **Supabase优先**
|
||||
- 使用 `auth.uid()` + RLS 进行数据隔离
|
||||
- 前端可直接安全地访问数据库
|
||||
|
||||
2. **性能优化**
|
||||
- 高频查询字段都有索引
|
||||
- 使用触发器维护汇总字段
|
||||
- 合理使用JSONB存储非结构化数据
|
||||
|
||||
3. **可扩展性**
|
||||
- 所有核心表都有`cid`(自增ID)和`slug`用于SEO
|
||||
- 模块化设计,支持功能扩展
|
||||
|
||||
## 常见问题
|
||||
|
||||
Q: 如何添加新状态?
|
||||
A: 需要更新CHECK约束、相关触发器和状态字典文档。
|
||||
|
||||
Q: 如何添加新表?
|
||||
A: 参考现有表结构,确保包含`id`(UUID)、`cid`(SERIAL)、`created_at`等标准字段。
|
||||
|
||||
Q: 如何测试RLS策略?
|
||||
A: 使用`set local request.jwt.claim.sub = 'user-id'`模拟不同用户访问。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. 修改数据库前请先更新相关文档
|
||||
2. 保持命名一致性
|
||||
3. 提供回滚脚本
|
||||
4. 更新版本号
|
||||
|
||||
## 版本历史
|
||||
|
||||
- v1.0.0 (2024-02-01): 初始版本
|
||||
- v1.1.0 (2024-02-15): 新增订阅模块
|
||||
|
||||
## 联系
|
||||
|
||||
如有问题,请联系数据库管理员或提交issue。
|
||||
@@ -177,6 +177,8 @@ $$;
|
||||
-- -------------------------------------------------------------------------------------
|
||||
-- 4. 函数: rpc_analytics_traffic_sources
|
||||
-- 描述: 获取流量来源分布 (基于注册来源)
|
||||
-- 兼容性说明:部分环境的 ak_users 可能不存在 registration_source 字段。
|
||||
-- 为避免 RPC 报错导致页面加载失败,这里做“字段存在则分组统计,不存在则全部归为未知”的兼容。
|
||||
-- -------------------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
|
||||
p_start_date DATE,
|
||||
@@ -186,16 +188,40 @@ RETURNS TABLE (
|
||||
name TEXT,
|
||||
value BIGINT
|
||||
)
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
SELECT
|
||||
COALESCE(registration_source, '未知') AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
|
||||
GROUP BY name
|
||||
ORDER BY value DESC;
|
||||
$$;
|
||||
LANGUAGE plpgsql
|
||||
AS $
|
||||
DECLARE
|
||||
has_registration_source BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ak_users'
|
||||
AND column_name = 'registration_source'
|
||||
) INTO has_registration_source;
|
||||
|
||||
IF has_registration_source THEN
|
||||
RETURN QUERY
|
||||
EXECUTE '
|
||||
SELECT
|
||||
COALESCE(registration_source, ''未知'') AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN $1 AND $2
|
||||
GROUP BY name
|
||||
ORDER BY value DESC
|
||||
'
|
||||
USING p_start_date, p_end_date;
|
||||
ELSE
|
||||
RETURN QUERY
|
||||
SELECT '未知'::TEXT AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date;
|
||||
END IF;
|
||||
END;
|
||||
$;
|
||||
|
||||
-- =====================================================================================
|
||||
-- 5. 完成提示
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'优惠券效果分析'"
|
||||
@@ -27,19 +27,34 @@
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
|
||||
<!-- 时间维度筛选 -->
|
||||
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="tab"
|
||||
:class="{ active: selectedPeriod === p.value }"
|
||||
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||
@click="selectPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</view>
|
||||
<view
|
||||
class="tab"
|
||||
:class="{ active: customRangeEnabled }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AnalyticsDateRangePicker
|
||||
v-if="customRangeEnabled"
|
||||
:initialStartDate="selectedStartDate"
|
||||
:initialEndDate="selectedEndDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
@@ -113,6 +128,7 @@ import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
import { fetchCouponAnalysis } from '@/services/analytics/couponAnalysisService.uts'
|
||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||
@@ -121,6 +137,11 @@ import type { TimePeriod } from '@/types/analytics/common.uts'
|
||||
|
||||
const lastUpdateTime = ref('')
|
||||
const selectedPeriod = ref('7d')
|
||||
|
||||
const customRangeEnabled = ref(false)
|
||||
const selectedStartDate = ref('')
|
||||
const selectedEndDate = ref('')
|
||||
|
||||
const showMoreMenu = ref(false)
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/coupon-analysis')
|
||||
@@ -165,7 +186,11 @@ onLoad(() => {
|
||||
|
||||
async function loadCouponData() {
|
||||
try {
|
||||
const data = await fetchCouponAnalysis(selectedPeriod.value)
|
||||
const range = selectedStartDate.value && selectedEndDate.value
|
||||
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||
: null
|
||||
|
||||
const data = await fetchCouponAnalysis(selectedPeriod.value, range)
|
||||
|
||||
const overviewRow = data.overviewRow
|
||||
const typeList = data.typeList
|
||||
@@ -227,6 +252,27 @@ async function loadCouponData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadCouponData()
|
||||
}
|
||||
|
||||
function toggleCustomRange() {
|
||||
customRangeEnabled.value = !customRangeEnabled.value
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
selectedStartDate.value = range.start
|
||||
selectedEndDate.value = range.end
|
||||
customRangeEnabled.value = true
|
||||
loadCouponData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
customRangeEnabled.value = false
|
||||
loadCouponData()
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uv
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import { goToLogin as goToLoginPage } from '@/utils/utils.uts'
|
||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
|
||||
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
|
||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||
|
||||
@@ -225,6 +226,7 @@ const chartTypes = ref<Array<ChartType>>([
|
||||
|
||||
onLoad(() => {
|
||||
currentPath.value = '/pages/mall/analytics/custom-report'
|
||||
if (!ensureAnalyticsLogin({ toastTitle: '请先登录后使用自定义报表' })) return
|
||||
loadReports()
|
||||
})
|
||||
|
||||
@@ -439,17 +441,27 @@ async function saveReport() {
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
// 检查 newReportId 是否有效,无效则认为创建失败
|
||||
if (newReportId == null || newReportId.length === 0) {
|
||||
uni.showToast({
|
||||
title: '创建失败:未返回报表ID',
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
closeModal()
|
||||
loadReports()
|
||||
|
||||
if (newReportId.length > 0) {
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
|
||||
})
|
||||
}, 400)
|
||||
}
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
|
||||
})
|
||||
}, 400)
|
||||
|
||||
} catch (e: any) {
|
||||
uni.hideLoading()
|
||||
console.error('saveReport exception:', e)
|
||||
@@ -493,6 +505,10 @@ function closeMoreMenu() {
|
||||
showMoreMenu.value = false
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
ensureAnalyticsLogin({ toastTitle: '请先登录后使用自定义报表' })
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
uni.showToast({ title: '搜索', icon: 'none' })
|
||||
}
|
||||
|
||||
@@ -27,19 +27,34 @@
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
|
||||
<!-- 时间维度筛选 -->
|
||||
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="tab"
|
||||
:class="{ active: selectedPeriod === p.value }"
|
||||
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||
@click="selectPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</view>
|
||||
<view
|
||||
class="tab"
|
||||
:class="{ active: customRangeEnabled }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AnalyticsDateRangePicker
|
||||
v-if="customRangeEnabled"
|
||||
:initialStartDate="selectedStartDate"
|
||||
:initialEndDate="selectedEndDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
@@ -120,6 +135,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
import { fetchDeliveryAnalysis } from '@/services/analytics/deliveryAnalysisService.uts'
|
||||
@@ -130,6 +146,11 @@ import type { DeliveryData, DriverRank } from '@/types/analytics/delivery.uts'
|
||||
|
||||
const lastUpdateTime = ref('')
|
||||
const selectedPeriod = ref('7d')
|
||||
|
||||
const customRangeEnabled = ref(false)
|
||||
const selectedStartDate = ref('')
|
||||
const selectedEndDate = ref('')
|
||||
|
||||
const showMoreMenu = ref(false)
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/delivery-analysis')
|
||||
@@ -171,7 +192,11 @@ onLoad(() => {
|
||||
|
||||
async function loadDeliveryData() {
|
||||
try {
|
||||
const data: any = await fetchDeliveryAnalysis(selectedPeriod.value)
|
||||
const range = selectedStartDate.value && selectedEndDate.value
|
||||
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||
: null
|
||||
|
||||
const data: any = await fetchDeliveryAnalysis(selectedPeriod.value, range)
|
||||
const trendList = data.trendList
|
||||
const topList = data.topList
|
||||
|
||||
@@ -263,6 +288,27 @@ async function loadDeliveryData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadDeliveryData()
|
||||
}
|
||||
|
||||
function toggleCustomRange() {
|
||||
customRangeEnabled.value = !customRangeEnabled.value
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
selectedStartDate.value = range.start
|
||||
selectedEndDate.value = range.end
|
||||
customRangeEnabled.value = true
|
||||
loadDeliveryData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
customRangeEnabled.value = false
|
||||
loadDeliveryData()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'数据分析中心'"
|
||||
@@ -275,6 +275,8 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
|
||||
|
||||
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
@@ -684,6 +686,12 @@ async function initDashboard() {
|
||||
|
||||
async function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
|
||||
// 切换到快捷时间段时,退出自定义范围
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([loadTrend(), loadUserSegments(), loadTrafficSources(), loadTopProducts(), loadTopMerchants()])
|
||||
@@ -852,7 +860,8 @@ function exportReport() {
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
initDashboard()
|
||||
if (!ensureAnalyticsLogin({ toastTitle: '请先登录后查看数据分析' })) return
|
||||
initDashboard()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'市场趋势'"
|
||||
@@ -27,19 +27,34 @@
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
|
||||
<!-- 时间维度筛选 -->
|
||||
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="tab"
|
||||
:class="{ active: selectedPeriod === p.value }"
|
||||
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||
@click="selectPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</view>
|
||||
<view
|
||||
class="tab"
|
||||
:class="{ active: customRangeEnabled }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AnalyticsDateRangePicker
|
||||
v-if="customRangeEnabled"
|
||||
:initialStartDate="selectedStartDate"
|
||||
:initialEndDate="selectedEndDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
|
||||
<!-- 市场整体趋势 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
@@ -101,6 +116,7 @@ import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uv
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import { fetchMarketTrends } from '@/services/analytics/marketTrendsService.uts'
|
||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||
|
||||
@@ -109,6 +125,11 @@ import type { MarketTrendsResponse } from '@/types/analytics/market.uts'
|
||||
|
||||
const lastUpdateTime = ref('')
|
||||
const selectedPeriod = ref('7d')
|
||||
|
||||
const customRangeEnabled = ref(false)
|
||||
const selectedStartDate = ref('')
|
||||
const selectedEndDate = ref('')
|
||||
|
||||
const showMoreMenu = ref(false)
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/market-trends')
|
||||
@@ -149,7 +170,11 @@ onShow(() => {
|
||||
|
||||
async function loadMarketData() {
|
||||
try {
|
||||
const data = (await fetchMarketTrends(selectedPeriod.value)) as MarketTrendsResponse
|
||||
const range = selectedStartDate.value && selectedEndDate.value
|
||||
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||
: null
|
||||
|
||||
const data = (await fetchMarketTrends(selectedPeriod.value, range)) as MarketTrendsResponse
|
||||
|
||||
_marketTrendRows.value = data.trendRows
|
||||
_industryRows.value = data.categoryRows
|
||||
@@ -169,6 +194,9 @@ async function loadMarketData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadMarketData()
|
||||
}
|
||||
|
||||
@@ -177,6 +205,24 @@ function refreshData() {
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
}
|
||||
|
||||
function toggleCustomRange() {
|
||||
customRangeEnabled.value = !customRangeEnabled.value
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
selectedStartDate.value = range.start
|
||||
selectedEndDate.value = range.end
|
||||
customRangeEnabled.value = true
|
||||
loadMarketData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
customRangeEnabled.value = false
|
||||
loadMarketData()
|
||||
}
|
||||
|
||||
function exportReport() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['导出Excel', '导出PDF', '导出图片'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'商品洞察'"
|
||||
@@ -27,19 +27,34 @@
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
|
||||
<!-- 时间维度筛选 -->
|
||||
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="tab"
|
||||
:class="{ active: selectedPeriod === p.value }"
|
||||
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||
@click="selectPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</view>
|
||||
<view
|
||||
class="tab"
|
||||
:class="{ active: customRangeEnabled }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AnalyticsDateRangePicker
|
||||
v-if="customRangeEnabled"
|
||||
:initialStartDate="selectedStartDate"
|
||||
:initialEndDate="selectedEndDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
@@ -173,6 +188,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
|
||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||
@@ -182,6 +198,11 @@ import type { ProductData, ProductRank } from '@/types/analytics/product.uts'
|
||||
|
||||
const lastUpdateTime = ref('')
|
||||
const selectedPeriod = ref('7d')
|
||||
|
||||
const customRangeEnabled = ref(false)
|
||||
const selectedStartDate = ref('')
|
||||
const selectedEndDate = ref('')
|
||||
|
||||
const showMoreMenu = ref(false)
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/product-insights')
|
||||
@@ -251,7 +272,11 @@ async function loadSelectedProductTrend() {
|
||||
return
|
||||
}
|
||||
|
||||
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value)
|
||||
const range = selectedStartDate.value && selectedEndDate.value
|
||||
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||
: null
|
||||
|
||||
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value, range)
|
||||
const rows: Array<any> = trend as any
|
||||
|
||||
const x: Array<string> = []
|
||||
@@ -367,12 +392,16 @@ async function loadProductData() {
|
||||
try {
|
||||
updateTime()
|
||||
|
||||
const range = selectedStartDate.value && selectedEndDate.value
|
||||
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||
: null
|
||||
|
||||
const [overview, topList, catRows, stockRows, _priceRows, reviewRows] = await Promise.all([
|
||||
fetchProductOverview(selectedPeriod.value),
|
||||
fetchTopProducts(selectedPeriod.value, 10),
|
||||
fetchCategorySales(selectedPeriod.value),
|
||||
fetchProductOverview(selectedPeriod.value, range),
|
||||
fetchTopProducts(selectedPeriod.value, 10, range),
|
||||
fetchCategorySales(selectedPeriod.value, range),
|
||||
fetchStockInsights(selectedPeriod.value),
|
||||
fetchPriceTrend(selectedPeriod.value),
|
||||
fetchPriceTrend(selectedPeriod.value, range),
|
||||
fetchReviewInsights()
|
||||
])
|
||||
|
||||
@@ -415,6 +444,27 @@ async function loadProductData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadProductData()
|
||||
}
|
||||
|
||||
function toggleCustomRange() {
|
||||
customRangeEnabled.value = !customRangeEnabled.value
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
selectedStartDate.value = range.start
|
||||
selectedEndDate.value = range.end
|
||||
customRangeEnabled.value = true
|
||||
loadProductData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
customRangeEnabled.value = false
|
||||
loadProductData()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'销售报表'"
|
||||
@@ -277,6 +277,9 @@ async function loadSalesData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadSalesData()
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,8 @@ $$;
|
||||
|
||||
|
||||
-- 4) 渠道来源(按注册来源,统计周期内新增用户来源)
|
||||
-- 兼容性说明:部分环境的 ak_users 可能不存在 registration_source 字段。
|
||||
-- 为避免 RPC 报错导致首页整体加载失败,这里做“字段存在则分组统计,不存在则全部归为未知”的兼容。
|
||||
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
|
||||
p_start_date DATE,
|
||||
p_end_date DATE
|
||||
@@ -194,13 +196,37 @@ RETURNS TABLE (
|
||||
name TEXT,
|
||||
value BIGINT
|
||||
)
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
SELECT
|
||||
COALESCE(registration_source, '未知') AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
|
||||
GROUP BY name
|
||||
ORDER BY value DESC;
|
||||
$$;
|
||||
LANGUAGE plpgsql
|
||||
AS $
|
||||
DECLARE
|
||||
has_registration_source BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ak_users'
|
||||
AND column_name = 'registration_source'
|
||||
) INTO has_registration_source;
|
||||
|
||||
IF has_registration_source THEN
|
||||
RETURN QUERY
|
||||
EXECUTE '
|
||||
SELECT
|
||||
COALESCE(registration_source, ''未知'') AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN $1 AND $2
|
||||
GROUP BY name
|
||||
ORDER BY value DESC
|
||||
'
|
||||
USING p_start_date, p_end_date;
|
||||
ELSE
|
||||
RETURN QUERY
|
||||
SELECT '未知'::TEXT AS name,
|
||||
COUNT(id)::BIGINT AS value
|
||||
FROM public.ak_users
|
||||
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date;
|
||||
END IF;
|
||||
END;
|
||||
$;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'用户分析'"
|
||||
@@ -36,14 +36,29 @@
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="tab"
|
||||
:class="{ active: selectedPeriod === p.value }"
|
||||
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||
@click="selectPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</view>
|
||||
<view
|
||||
class="tab"
|
||||
:class="{ active: customRangeEnabled }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AnalyticsDateRangePicker
|
||||
v-if="customRangeEnabled"
|
||||
:initialStartDate="selectedStartDate"
|
||||
:initialEndDate="selectedEndDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
|
||||
<view class="filter-hint">
|
||||
<text class="filter-hint-text">渠道/终端/会员/新老:待接入数据后开放</text>
|
||||
</view>
|
||||
@@ -210,6 +225,7 @@
|
||||
<script setup lang="uts">
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||
@@ -222,6 +238,11 @@ import type { UserData, FunnelStep } from '@/types/analytics/user.uts'
|
||||
|
||||
const lastUpdateTime = ref('')
|
||||
const selectedPeriod = ref('7d')
|
||||
|
||||
const customRangeEnabled = ref(false)
|
||||
const selectedStartDate = ref('')
|
||||
const selectedEndDate = ref('')
|
||||
|
||||
const showMoreMenu = ref(false)
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/user-analysis')
|
||||
@@ -297,6 +318,10 @@ onLoad(() => {
|
||||
})
|
||||
|
||||
function calcDateRange() {
|
||||
if (selectedStartDate.value && selectedEndDate.value) {
|
||||
return { startDate: new Date(selectedStartDate.value), endDate: new Date(selectedEndDate.value) }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = selectedPeriod.value === '7d' ? 7 : selectedPeriod.value === '30d' ? 30 : selectedPeriod.value === '90d' ? 90 : 365
|
||||
@@ -440,6 +465,27 @@ async function loadUserData() {
|
||||
|
||||
function selectPeriod(p: string) {
|
||||
selectedPeriod.value = p
|
||||
customRangeEnabled.value = false
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
loadUserData()
|
||||
}
|
||||
|
||||
function toggleCustomRange() {
|
||||
customRangeEnabled.value = !customRangeEnabled.value
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
selectedStartDate.value = range.start
|
||||
selectedEndDate.value = range.end
|
||||
customRangeEnabled.value = true
|
||||
loadUserData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
selectedStartDate.value = ''
|
||||
selectedEndDate.value = ''
|
||||
customRangeEnabled.value = false
|
||||
loadUserData()
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,20 @@ const handleLogin = async () => {
|
||||
}
|
||||
|
||||
const navigateToRegister = () => {
|
||||
uni.navigateTo({ url: '/pages/user/register' })
|
||||
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.navigateTo({
|
||||
url: `/pages/user/register?redirect=${redirect}`
|
||||
})
|
||||
} else {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/register'
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleTutorial = () => uni.showToast({ title: '扫码教程开发中', icon: 'none' })
|
||||
const handleForgotPassword = () => uni.showToast({ title: '忘记密码开发中', icon: 'none' })
|
||||
|
||||
@@ -330,9 +330,20 @@
|
||||
|
||||
// 跳转到登录页
|
||||
const navigateToLogin = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
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.navigateTo({
|
||||
url: `/pages/user/login?redirect=${redirect}`
|
||||
})
|
||||
} else {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到协议页面
|
||||
|
||||
20
services/analytics/authGuard.uts
Normal file
20
services/analytics/authGuard.uts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getUserIdOrNull } from './auth.uts'
|
||||
import { toLoginWithRedirect, getCurrentPageUrlWithQuery } from '@/utils/authRedirect.uts'
|
||||
|
||||
export type EnsureLoginOptions = {
|
||||
redirectUrl?: string
|
||||
toastTitle?: string
|
||||
}
|
||||
|
||||
export function ensureAnalyticsLogin(opts?: EnsureLoginOptions): boolean {
|
||||
const uid = getUserIdOrNull()
|
||||
if (uid != null && uid !== '') return true
|
||||
|
||||
const target = (opts?.redirectUrl != null && opts?.redirectUrl?.length > 0)
|
||||
? opts?.redirectUrl as string
|
||||
: getCurrentPageUrlWithQuery()
|
||||
|
||||
uni.showToast({ title: opts?.toastTitle ?? '请先登录', icon: 'none' })
|
||||
toLoginWithRedirect(target)
|
||||
return false
|
||||
}
|
||||
@@ -9,8 +9,19 @@ export type CouponAnalysisData = {
|
||||
conversionList: Array<UTSJSONObject>
|
||||
}
|
||||
|
||||
export async function fetchCouponAnalysis(period: string): Promise<CouponAnalysisData> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchCouponAnalysis(period: string, range?: { start: string; end: string } | null): Promise<CouponAnalysisData> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const p_start_date = toDateOnly(startIso)
|
||||
const p_end_date = toDateOnly(endIso)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ function safeString(v: any): string {
|
||||
|
||||
// 改造:不再直查 analytics_reports 表,统一通过 RPC 获取当前用户的报表列表
|
||||
export async function listCustomReports(ownerUserId: string): Promise<Array<CustomReportListItem>> {
|
||||
// rpc_get_custom_reports 基于 auth.uid() 过滤,无需额外参数,这里保留签名用于兼容调用方
|
||||
const rows = await rpcOrEmptyArray('rpc_get_custom_reports', {} as any)
|
||||
const list: Array<CustomReportListItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
@@ -37,8 +38,7 @@ export async function listCustomReports(ownerUserId: string): Promise<Array<Cust
|
||||
id: safeString(r.getAny?.('id') ?? r.getString?.('id')),
|
||||
title: safeString(r.getAny?.('title') ?? r.getString?.('title')),
|
||||
description: safeString(r.getAny?.('description') ?? r.getString?.('description')),
|
||||
// 兼容旧 UI 字段:custom-report 页面里可能还在用 period 字段
|
||||
period: '',
|
||||
period: safeString(r.getAny?.('period') ?? r.getString?.('period')),
|
||||
updated_at: safeString(r.getAny?.('updated_at') ?? r.getString?.('updated_at'))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,18 @@ export type DeliveryAnalysisData = {
|
||||
endIso: string
|
||||
}
|
||||
|
||||
export async function fetchDeliveryAnalysis(period: string): Promise<DeliveryAnalysisData> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchDeliveryAnalysis(period: string, range?: { start: string; end: string } | null): Promise<DeliveryAnalysisData> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const trendList = await rpcOrEmptyArray('rpc_delivery_efficiency_daily', {
|
||||
p_start: startIso,
|
||||
|
||||
@@ -11,8 +11,19 @@ export type MarketTrendsData = {
|
||||
endIso: string
|
||||
}
|
||||
|
||||
export async function fetchMarketTrends(period: string): Promise<MarketTrendsData> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchMarketTrends(period: string, range?: { start: string; end: string } | null): Promise<MarketTrendsData> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const startDate = toDateOnly(startIso)
|
||||
const endDate = toDateOnly(endIso)
|
||||
|
||||
|
||||
@@ -20,8 +20,19 @@ function safeNumber(v: any): number {
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export async function fetchProductOverview(period: string): Promise<ProductOverview> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchProductOverview(period: string, range?: { start: string; end: string } | null): Promise<ProductOverview> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const row = await rpcOrNull('rpc_product_insights_overview', {
|
||||
p_start: toDateOnly(startIso),
|
||||
p_end: toDateOnly(endIso)
|
||||
@@ -39,8 +50,19 @@ export async function fetchProductOverview(period: string): Promise<ProductOverv
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTopProducts(period: string, limit: number = 10): Promise<Array<ProductRank>> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchTopProducts(period: string, limit: number = 10, range?: { start: string; end: string } | null): Promise<Array<ProductRank>> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
|
||||
p_start_date: toDateOnly(startIso),
|
||||
p_end_date: toDateOnly(endIso),
|
||||
@@ -61,8 +83,19 @@ export async function fetchTopProducts(period: string, limit: number = 10): Prom
|
||||
return list
|
||||
}
|
||||
|
||||
export async function fetchProductTrend(period: string, productId: string): Promise<Array<ProductTrendRow>> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchProductTrend(period: string, productId: string, range?: { start: string; end: string } | null): Promise<Array<ProductTrendRow>> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
const rows = await rpcOrEmptyArray('rpc_analytics_product_trend', {
|
||||
p_start_date: toDateOnly(startIso),
|
||||
p_end_date: toDateOnly(endIso),
|
||||
@@ -83,8 +116,19 @@ export async function fetchProductTrend(period: string, productId: string): Prom
|
||||
return out
|
||||
}
|
||||
|
||||
export async function fetchCategorySales(period: string): Promise<Array<UTSJSONObject>> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchCategorySales(period: string, range?: { start: string; end: string } | null): Promise<Array<UTSJSONObject>> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
return await rpcOrEmptyArray('rpc_analytics_category_sales', {
|
||||
p_start_date: toDateOnly(startIso),
|
||||
p_end_date: toDateOnly(endIso)
|
||||
@@ -95,8 +139,19 @@ export async function fetchStockInsights(period: string): Promise<Array<UTSJSONO
|
||||
return await rpcOrEmptyArray('rpc_product_insights_stock', {} as any)
|
||||
}
|
||||
|
||||
export async function fetchPriceTrend(period: string): Promise<Array<UTSJSONObject>> {
|
||||
const { startIso, endIso } = computeDateRange(period)
|
||||
export async function fetchPriceTrend(period: string, range?: { start: string; end: string } | null): Promise<Array<UTSJSONObject>> {
|
||||
let startIso: string;
|
||||
let endIso: string;
|
||||
|
||||
if (range != null && range.start && range.end) {
|
||||
startIso = range.start;
|
||||
endIso = range.end;
|
||||
} else {
|
||||
const computedRange = computeDateRange(period)
|
||||
startIso = computedRange.startIso
|
||||
endIso = computedRange.endIso
|
||||
}
|
||||
|
||||
return await rpcOrEmptyArray('rpc_analytics_price_trend', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
|
||||
62
utils/authRedirect.uts
Normal file
62
utils/authRedirect.uts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type AuthRedirectOptions = {
|
||||
loginPath?: string
|
||||
registerPath?: string
|
||||
fallbackUrl?: string
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path) return ''
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function safeEncode(url: string): string {
|
||||
try {
|
||||
return encodeURIComponent(url)
|
||||
} catch (_) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentPageUrlWithQuery(): string {
|
||||
const pages = getCurrentPages() as any[]
|
||||
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||
if (!currentPage) return ''
|
||||
|
||||
const route = currentPage.route as string | null
|
||||
const options = (currentPage.options ?? {}) as Record<string, any>
|
||||
if (!route) return ''
|
||||
|
||||
const path = normalizePath(route)
|
||||
const keys = Object.keys(options)
|
||||
if (keys.length === 0) return path
|
||||
|
||||
const query = keys
|
||||
.filter((k) => options[k] != null)
|
||||
.map((k) => `${k}=${encodeURIComponent(String(options[k]))}`)
|
||||
.join('&')
|
||||
return query.length > 0 ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
export function toLoginWithRedirect(redirectUrl?: string, opts?: AuthRedirectOptions) {
|
||||
const loginPath = opts?.loginPath ?? '/pages/user/login'
|
||||
const fallback = opts?.fallbackUrl ?? '/pages/mall/consumer/index'
|
||||
const target = (redirectUrl != null && redirectUrl.length > 0)
|
||||
? redirectUrl
|
||||
: (getCurrentPageUrlWithQuery() || fallback)
|
||||
|
||||
uni.navigateTo({
|
||||
url: `${loginPath}?redirect=${safeEncode(target)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function toRegisterWithRedirect(redirectUrl?: string, opts?: AuthRedirectOptions) {
|
||||
const registerPath = opts?.registerPath ?? '/pages/user/register'
|
||||
const fallback = opts?.fallbackUrl ?? '/pages/mall/consumer/index'
|
||||
const target = (redirectUrl != null && redirectUrl.length > 0)
|
||||
? redirectUrl
|
||||
: (getCurrentPageUrlWithQuery() || fallback)
|
||||
|
||||
uni.navigateTo({
|
||||
url: `${registerPath}?redirect=${safeEncode(target)}`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user