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:
2026-02-02 13:14:53 +00:00
37 changed files with 3261 additions and 89 deletions

View File

@@ -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 运行与编译

View File

@@ -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: [

View File

@@ -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
View 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
View File

@@ -0,0 +1,32 @@
# 00 概览:商城数据库总体设计
## 目标与定位
本数据库设计面向 **PostgreSQL + Supabase** 的电商/订阅混合业务,核心目标:
- **统一用户体系复用**:复用 `public.ak_users`,商城域只做扩展(`ml_` 前缀)。
- **安全优先Supabase 直连友好)**:使用 RLSRow 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`

View 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`

View 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`,建议在项目侧补齐或在应用层保证一致性)

View 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`:已过期

View 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、空字符等边界

View File

@@ -0,0 +1,159 @@
# 05 RLS 权限矩阵Supabase 行级安全)
本节整理 `complete_mall_database.sql` 中的 RLSRow 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`)有索引

View 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
-- 方式 1cid
select * from public.get_product_by_cid(12345);
-- 方式 2slug
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`)。

View 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` 建立外键关联,支付/对账链路通常在应用层或另一个交易子系统实现。

View 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. 推荐补充的“最小一致性清单”(可用于评审)
- 下单扣减库存是否原子?
- 未支付关闭订单是否回补库存?
- 支付回调是否幂等?
- 退款回调是否幂等?
- 优惠券核销是否并发安全?
- 统计字段口径是否明确、是否需要回滚?

View 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 与路由)。

View 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`

View 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
View 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
View 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。

View File

@@ -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. 完成提示

View File

@@ -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()
}

View File

@@ -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' })
}

View File

@@ -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()
}

View File

@@ -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(() => {

View File

@@ -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', '导出图片'],

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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;
$;

View File

@@ -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()
}

View File

@@ -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' })

View File

@@ -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'
})
}
}
// 跳转到协议页面

View 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
}

View File

@@ -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)

View File

@@ -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'))
})
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
View 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)}`
})
}