diff --git a/.pages-backup/pages.consumer.2026-05-29T00-45-22-914Z.json b/.pages-backup/pages.consumer.2026-05-29T00-45-22-914Z.json
new file mode 100644
index 00000000..b2ea96e6
--- /dev/null
+++ b/.pages-backup/pages.consumer.2026-05-29T00-45-22-914Z.json
@@ -0,0 +1,464 @@
+{
+ "pages": [
+ {
+ "path": "pages/main/index",
+ "style": {
+ "navigationBarTitleText": "首页",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false
+ }
+ },
+ {
+ "path": "pages/user/boot",
+ "style": {
+ "navigationBarTitleText": ""
+ }
+ },
+ {
+ "path": "pages/user/login",
+ "style": {
+ "navigationBarTitleText": "用户登录",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/user/register",
+ "style": {
+ "navigationBarTitleText": "注册"
+ }
+ },
+ {
+ "path": "pages/user/forgot-password",
+ "style": {
+ "navigationBarTitleText": "忘记密码"
+ }
+ },
+ {
+ "path": "pages/user/terms",
+ "style": {
+ "navigationBarTitleText": "用户协议与隐私政策"
+ }
+ },
+ {
+ "path": "pages/user/center",
+ "style": {
+ "navigationBarTitleText": "用户中心"
+ }
+ },
+ {
+ "path": "pages/user/profile",
+ "style": {
+ "navigationBarTitleText": "个人资料"
+ }
+ },
+ {
+ "path": "pages/user/change-password",
+ "style": {
+ "navigationBarTitleText": "修改密码"
+ }
+ },
+ {
+ "path": "pages/user/bind-phone",
+ "style": {
+ "navigationBarTitleText": "绑定手机"
+ }
+ },
+ {
+ "path": "pages/user/bind-email",
+ "style": {
+ "navigationBarTitleText": "绑定邮箱"
+ }
+ },
+ {
+ "path": "pages/main/messages",
+ "style": {
+ "navigationBarTitleText": "消息",
+ "enablePullDownRefresh": true
+ }
+ },
+ {
+ "path": "pages/main/cart",
+ "style": {
+ "navigationBarTitleText": "购物车",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/profile",
+ "style": {
+ "navigationBarTitleText": "我的",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/category",
+ "style": {
+ "navigationBarTitleText": "分类",
+ "navigationStyle": "custom"
+ }
+ }
+ ],
+ "subPackages": [
+ {
+ "root": "pages/mall/consumer",
+ "pages": [
+ {
+ "path": "settings",
+ "style": {
+ "navigationBarTitleText": "设置"
+ }
+ },
+ {
+ "path": "edit-profile",
+ "style": {
+ "navigationBarTitleText": "编辑资料"
+ }
+ },
+ {
+ "path": "wallet",
+ "style": {
+ "navigationBarTitleText": "我的钱包"
+ }
+ },
+ {
+ "path": "withdraw",
+ "style": {
+ "navigationBarTitleText": "余额提现"
+ }
+ },
+ {
+ "path": "search",
+ "style": {
+ "navigationBarTitleText": "搜索",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "product-detail",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "channel-detail",
+ "style": {
+ "navigationBarTitleText": "频道详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "shop-detail",
+ "style": {
+ "navigationBarTitleText": "店铺详情"
+ }
+ },
+ {
+ "path": "coupons",
+ "style": {
+ "navigationBarTitleText": "我的优惠券"
+ }
+ },
+ {
+ "path": "favorites",
+ "style": {
+ "navigationBarTitleText": "我的收藏"
+ }
+ },
+ {
+ "path": "footprint",
+ "style": {
+ "navigationBarTitleText": "我的足迹"
+ }
+ },
+ {
+ "path": "address",
+ "style": {
+ "navigationBarTitleText": "地址"
+ }
+ },
+ {
+ "path": "address-list",
+ "style": {
+ "navigationBarTitleText": "收货地址"
+ }
+ },
+ {
+ "path": "address-edit",
+ "style": {
+ "navigationBarTitleText": "编辑地址"
+ }
+ },
+ {
+ "path": "checkout",
+ "style": {
+ "navigationBarTitleText": "确认订单"
+ }
+ },
+ {
+ "path": "payment",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "payment-success",
+ "style": {
+ "navigationBarTitleText": "支付成功",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "orders",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": true,
+ "backgroundColor": "#f5f5f5"
+ }
+ },
+ {
+ "path": "order-detail",
+ "style": {
+ "navigationBarTitleText": "订单详情"
+ }
+ },
+ {
+ "path": "logistics",
+ "style": {
+ "navigationBarTitleText": "物流详情"
+ }
+ },
+ {
+ "path": "review",
+ "style": {
+ "navigationBarTitleText": "评价晒单"
+ }
+ },
+ {
+ "path": "refund",
+ "style": {
+ "navigationBarTitleText": "退款/售后"
+ }
+ },
+ {
+ "path": "apply-refund",
+ "style": {
+ "navigationBarTitleText": "申请售后"
+ }
+ },
+ {
+ "path": "refund-review",
+ "style": {
+ "navigationBarTitleText": "服务评价"
+ }
+ },
+ {
+ "path": "chat",
+ "style": {
+ "navigationBarTitleText": "客服聊天",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "chat_new",
+ "style": {
+ "navigationBarTitleText": "客服聊天(新版)"
+ }
+ },
+ {
+ "path": "subscription/plan-list",
+ "style": {
+ "navigationBarTitleText": "软件订阅"
+ }
+ },
+ {
+ "path": "subscription/plan-detail",
+ "style": {
+ "navigationBarTitleText": "订阅详情"
+ }
+ },
+ {
+ "path": "subscription/subscribe-checkout",
+ "style": {
+ "navigationBarTitleText": "确认订阅"
+ }
+ },
+ {
+ "path": "subscription/my-subscriptions",
+ "style": {
+ "navigationBarTitleText": "我的订阅"
+ }
+ },
+ {
+ "path": "subscription/followed-shops",
+ "style": {
+ "navigationBarTitleText": "关注店铺"
+ }
+ },
+ {
+ "path": "points/index",
+ "style": {
+ "navigationBarTitleText": "积分管理"
+ }
+ },
+ {
+ "path": "points/signin",
+ "style": {
+ "navigationBarTitleText": "签到"
+ }
+ },
+ {
+ "path": "points/exchange",
+ "style": {
+ "navigationBarTitleText": "积分兑换"
+ }
+ },
+ {
+ "path": "points/exchange-records",
+ "style": {
+ "navigationBarTitleText": "兑换记录"
+ }
+ },
+ {
+ "path": "red-packets/index",
+ "style": {
+ "navigationBarTitleText": "我的红包"
+ }
+ },
+ {
+ "path": "bank-cards/index",
+ "style": {
+ "navigationBarTitleText": "银行卡管理"
+ }
+ },
+ {
+ "path": "bank-cards/add",
+ "style": {
+ "navigationBarTitleText": "添加银行卡"
+ }
+ },
+ {
+ "path": "home-service/index",
+ "style": {
+ "navigationBarTitleText": "居家上门服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/apply",
+ "style": {
+ "navigationBarTitleText": "提交服务申请",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/service-detail",
+ "style": {
+ "navigationBarTitleText": "预约服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/order-detail",
+ "style": {
+ "navigationBarTitleText": "服务单详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/feedback",
+ "style": {
+ "navigationBarTitleText": "验收反馈",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "bank-cards/verify",
+ "style": {
+ "navigationBarTitleText": "银行卡验证"
+ }
+ },
+ {
+ "path": "balance/index",
+ "style": {
+ "navigationBarTitleText": "余额"
+ }
+ },
+ {
+ "path": "my-reviews",
+ "style": {
+ "navigationBarTitleText": "我的评价"
+ }
+ },
+ {
+ "path": "message-detail",
+ "style": {
+ "navigationBarTitleText": "消息详情"
+ }
+ },
+ {
+ "path": "member/index",
+ "style": {
+ "navigationBarTitleText": "会员中心"
+ }
+ },
+ {
+ "path": "product-reviews",
+ "style": {
+ "navigationBarTitleText": "商品评价"
+ }
+ }
+ ]
+ }
+ ],
+ "tabBar": {
+ "color": "#999999",
+ "selectedColor": "#ff5000",
+ "backgroundColor": "#ffffff",
+ "borderStyle": "black",
+ "list": [
+ {
+ "pagePath": "pages/main/index",
+ "text": "首页",
+ "iconPath": "static/tabbar/home.png",
+ "selectedIconPath": "static/tabbar/home-active.png"
+ },
+ {
+ "pagePath": "pages/main/messages",
+ "text": "消息",
+ "iconPath": "static/tabbar/message.png",
+ "selectedIconPath": "static/tabbar/message.png"
+ },
+ {
+ "pagePath": "pages/main/cart",
+ "text": "购物车",
+ "iconPath": "static/tabbar/cart.png",
+ "selectedIconPath": "static/tabbar/cart.png"
+ },
+ {
+ "pagePath": "pages/main/profile",
+ "text": "我的",
+ "iconPath": "static/tabbar/user.png",
+ "selectedIconPath": "static/tabbar/user.png"
+ }
+ ]
+ },
+ "globalStyle": {
+ "navigationBarTextStyle": "black",
+ "navigationBarTitleText": "mall",
+ "navigationBarBackgroundColor": "#FFFFFF",
+ "backgroundColor": "#F8F8F8"
+ },
+ "condition": {
+ "current": 0,
+ "list": [
+ {
+ "name": "consumer端",
+ "path": "pages/main/index",
+ "query": "role=consumer"
+ }
+ ]
+ }
+}
diff --git a/.pages-backup/pages.consumer.2026-06-01T00-44-25-633Z.json b/.pages-backup/pages.consumer.2026-06-01T00-44-25-633Z.json
new file mode 100644
index 00000000..b2ea96e6
--- /dev/null
+++ b/.pages-backup/pages.consumer.2026-06-01T00-44-25-633Z.json
@@ -0,0 +1,464 @@
+{
+ "pages": [
+ {
+ "path": "pages/main/index",
+ "style": {
+ "navigationBarTitleText": "首页",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false
+ }
+ },
+ {
+ "path": "pages/user/boot",
+ "style": {
+ "navigationBarTitleText": ""
+ }
+ },
+ {
+ "path": "pages/user/login",
+ "style": {
+ "navigationBarTitleText": "用户登录",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/user/register",
+ "style": {
+ "navigationBarTitleText": "注册"
+ }
+ },
+ {
+ "path": "pages/user/forgot-password",
+ "style": {
+ "navigationBarTitleText": "忘记密码"
+ }
+ },
+ {
+ "path": "pages/user/terms",
+ "style": {
+ "navigationBarTitleText": "用户协议与隐私政策"
+ }
+ },
+ {
+ "path": "pages/user/center",
+ "style": {
+ "navigationBarTitleText": "用户中心"
+ }
+ },
+ {
+ "path": "pages/user/profile",
+ "style": {
+ "navigationBarTitleText": "个人资料"
+ }
+ },
+ {
+ "path": "pages/user/change-password",
+ "style": {
+ "navigationBarTitleText": "修改密码"
+ }
+ },
+ {
+ "path": "pages/user/bind-phone",
+ "style": {
+ "navigationBarTitleText": "绑定手机"
+ }
+ },
+ {
+ "path": "pages/user/bind-email",
+ "style": {
+ "navigationBarTitleText": "绑定邮箱"
+ }
+ },
+ {
+ "path": "pages/main/messages",
+ "style": {
+ "navigationBarTitleText": "消息",
+ "enablePullDownRefresh": true
+ }
+ },
+ {
+ "path": "pages/main/cart",
+ "style": {
+ "navigationBarTitleText": "购物车",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/profile",
+ "style": {
+ "navigationBarTitleText": "我的",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/category",
+ "style": {
+ "navigationBarTitleText": "分类",
+ "navigationStyle": "custom"
+ }
+ }
+ ],
+ "subPackages": [
+ {
+ "root": "pages/mall/consumer",
+ "pages": [
+ {
+ "path": "settings",
+ "style": {
+ "navigationBarTitleText": "设置"
+ }
+ },
+ {
+ "path": "edit-profile",
+ "style": {
+ "navigationBarTitleText": "编辑资料"
+ }
+ },
+ {
+ "path": "wallet",
+ "style": {
+ "navigationBarTitleText": "我的钱包"
+ }
+ },
+ {
+ "path": "withdraw",
+ "style": {
+ "navigationBarTitleText": "余额提现"
+ }
+ },
+ {
+ "path": "search",
+ "style": {
+ "navigationBarTitleText": "搜索",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "product-detail",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "channel-detail",
+ "style": {
+ "navigationBarTitleText": "频道详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "shop-detail",
+ "style": {
+ "navigationBarTitleText": "店铺详情"
+ }
+ },
+ {
+ "path": "coupons",
+ "style": {
+ "navigationBarTitleText": "我的优惠券"
+ }
+ },
+ {
+ "path": "favorites",
+ "style": {
+ "navigationBarTitleText": "我的收藏"
+ }
+ },
+ {
+ "path": "footprint",
+ "style": {
+ "navigationBarTitleText": "我的足迹"
+ }
+ },
+ {
+ "path": "address",
+ "style": {
+ "navigationBarTitleText": "地址"
+ }
+ },
+ {
+ "path": "address-list",
+ "style": {
+ "navigationBarTitleText": "收货地址"
+ }
+ },
+ {
+ "path": "address-edit",
+ "style": {
+ "navigationBarTitleText": "编辑地址"
+ }
+ },
+ {
+ "path": "checkout",
+ "style": {
+ "navigationBarTitleText": "确认订单"
+ }
+ },
+ {
+ "path": "payment",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "payment-success",
+ "style": {
+ "navigationBarTitleText": "支付成功",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "orders",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": true,
+ "backgroundColor": "#f5f5f5"
+ }
+ },
+ {
+ "path": "order-detail",
+ "style": {
+ "navigationBarTitleText": "订单详情"
+ }
+ },
+ {
+ "path": "logistics",
+ "style": {
+ "navigationBarTitleText": "物流详情"
+ }
+ },
+ {
+ "path": "review",
+ "style": {
+ "navigationBarTitleText": "评价晒单"
+ }
+ },
+ {
+ "path": "refund",
+ "style": {
+ "navigationBarTitleText": "退款/售后"
+ }
+ },
+ {
+ "path": "apply-refund",
+ "style": {
+ "navigationBarTitleText": "申请售后"
+ }
+ },
+ {
+ "path": "refund-review",
+ "style": {
+ "navigationBarTitleText": "服务评价"
+ }
+ },
+ {
+ "path": "chat",
+ "style": {
+ "navigationBarTitleText": "客服聊天",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "chat_new",
+ "style": {
+ "navigationBarTitleText": "客服聊天(新版)"
+ }
+ },
+ {
+ "path": "subscription/plan-list",
+ "style": {
+ "navigationBarTitleText": "软件订阅"
+ }
+ },
+ {
+ "path": "subscription/plan-detail",
+ "style": {
+ "navigationBarTitleText": "订阅详情"
+ }
+ },
+ {
+ "path": "subscription/subscribe-checkout",
+ "style": {
+ "navigationBarTitleText": "确认订阅"
+ }
+ },
+ {
+ "path": "subscription/my-subscriptions",
+ "style": {
+ "navigationBarTitleText": "我的订阅"
+ }
+ },
+ {
+ "path": "subscription/followed-shops",
+ "style": {
+ "navigationBarTitleText": "关注店铺"
+ }
+ },
+ {
+ "path": "points/index",
+ "style": {
+ "navigationBarTitleText": "积分管理"
+ }
+ },
+ {
+ "path": "points/signin",
+ "style": {
+ "navigationBarTitleText": "签到"
+ }
+ },
+ {
+ "path": "points/exchange",
+ "style": {
+ "navigationBarTitleText": "积分兑换"
+ }
+ },
+ {
+ "path": "points/exchange-records",
+ "style": {
+ "navigationBarTitleText": "兑换记录"
+ }
+ },
+ {
+ "path": "red-packets/index",
+ "style": {
+ "navigationBarTitleText": "我的红包"
+ }
+ },
+ {
+ "path": "bank-cards/index",
+ "style": {
+ "navigationBarTitleText": "银行卡管理"
+ }
+ },
+ {
+ "path": "bank-cards/add",
+ "style": {
+ "navigationBarTitleText": "添加银行卡"
+ }
+ },
+ {
+ "path": "home-service/index",
+ "style": {
+ "navigationBarTitleText": "居家上门服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/apply",
+ "style": {
+ "navigationBarTitleText": "提交服务申请",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/service-detail",
+ "style": {
+ "navigationBarTitleText": "预约服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/order-detail",
+ "style": {
+ "navigationBarTitleText": "服务单详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/feedback",
+ "style": {
+ "navigationBarTitleText": "验收反馈",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "bank-cards/verify",
+ "style": {
+ "navigationBarTitleText": "银行卡验证"
+ }
+ },
+ {
+ "path": "balance/index",
+ "style": {
+ "navigationBarTitleText": "余额"
+ }
+ },
+ {
+ "path": "my-reviews",
+ "style": {
+ "navigationBarTitleText": "我的评价"
+ }
+ },
+ {
+ "path": "message-detail",
+ "style": {
+ "navigationBarTitleText": "消息详情"
+ }
+ },
+ {
+ "path": "member/index",
+ "style": {
+ "navigationBarTitleText": "会员中心"
+ }
+ },
+ {
+ "path": "product-reviews",
+ "style": {
+ "navigationBarTitleText": "商品评价"
+ }
+ }
+ ]
+ }
+ ],
+ "tabBar": {
+ "color": "#999999",
+ "selectedColor": "#ff5000",
+ "backgroundColor": "#ffffff",
+ "borderStyle": "black",
+ "list": [
+ {
+ "pagePath": "pages/main/index",
+ "text": "首页",
+ "iconPath": "static/tabbar/home.png",
+ "selectedIconPath": "static/tabbar/home-active.png"
+ },
+ {
+ "pagePath": "pages/main/messages",
+ "text": "消息",
+ "iconPath": "static/tabbar/message.png",
+ "selectedIconPath": "static/tabbar/message.png"
+ },
+ {
+ "pagePath": "pages/main/cart",
+ "text": "购物车",
+ "iconPath": "static/tabbar/cart.png",
+ "selectedIconPath": "static/tabbar/cart.png"
+ },
+ {
+ "pagePath": "pages/main/profile",
+ "text": "我的",
+ "iconPath": "static/tabbar/user.png",
+ "selectedIconPath": "static/tabbar/user.png"
+ }
+ ]
+ },
+ "globalStyle": {
+ "navigationBarTextStyle": "black",
+ "navigationBarTitleText": "mall",
+ "navigationBarBackgroundColor": "#FFFFFF",
+ "backgroundColor": "#F8F8F8"
+ },
+ "condition": {
+ "current": 0,
+ "list": [
+ {
+ "name": "consumer端",
+ "path": "pages/main/index",
+ "query": "role=consumer"
+ }
+ ]
+ }
+}
diff --git a/.pages-backup/pages.consumer.2026-06-01T01-24-34-528Z.json b/.pages-backup/pages.consumer.2026-06-01T01-24-34-528Z.json
new file mode 100644
index 00000000..b2ea96e6
--- /dev/null
+++ b/.pages-backup/pages.consumer.2026-06-01T01-24-34-528Z.json
@@ -0,0 +1,464 @@
+{
+ "pages": [
+ {
+ "path": "pages/main/index",
+ "style": {
+ "navigationBarTitleText": "首页",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false
+ }
+ },
+ {
+ "path": "pages/user/boot",
+ "style": {
+ "navigationBarTitleText": ""
+ }
+ },
+ {
+ "path": "pages/user/login",
+ "style": {
+ "navigationBarTitleText": "用户登录",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/user/register",
+ "style": {
+ "navigationBarTitleText": "注册"
+ }
+ },
+ {
+ "path": "pages/user/forgot-password",
+ "style": {
+ "navigationBarTitleText": "忘记密码"
+ }
+ },
+ {
+ "path": "pages/user/terms",
+ "style": {
+ "navigationBarTitleText": "用户协议与隐私政策"
+ }
+ },
+ {
+ "path": "pages/user/center",
+ "style": {
+ "navigationBarTitleText": "用户中心"
+ }
+ },
+ {
+ "path": "pages/user/profile",
+ "style": {
+ "navigationBarTitleText": "个人资料"
+ }
+ },
+ {
+ "path": "pages/user/change-password",
+ "style": {
+ "navigationBarTitleText": "修改密码"
+ }
+ },
+ {
+ "path": "pages/user/bind-phone",
+ "style": {
+ "navigationBarTitleText": "绑定手机"
+ }
+ },
+ {
+ "path": "pages/user/bind-email",
+ "style": {
+ "navigationBarTitleText": "绑定邮箱"
+ }
+ },
+ {
+ "path": "pages/main/messages",
+ "style": {
+ "navigationBarTitleText": "消息",
+ "enablePullDownRefresh": true
+ }
+ },
+ {
+ "path": "pages/main/cart",
+ "style": {
+ "navigationBarTitleText": "购物车",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/profile",
+ "style": {
+ "navigationBarTitleText": "我的",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/main/category",
+ "style": {
+ "navigationBarTitleText": "分类",
+ "navigationStyle": "custom"
+ }
+ }
+ ],
+ "subPackages": [
+ {
+ "root": "pages/mall/consumer",
+ "pages": [
+ {
+ "path": "settings",
+ "style": {
+ "navigationBarTitleText": "设置"
+ }
+ },
+ {
+ "path": "edit-profile",
+ "style": {
+ "navigationBarTitleText": "编辑资料"
+ }
+ },
+ {
+ "path": "wallet",
+ "style": {
+ "navigationBarTitleText": "我的钱包"
+ }
+ },
+ {
+ "path": "withdraw",
+ "style": {
+ "navigationBarTitleText": "余额提现"
+ }
+ },
+ {
+ "path": "search",
+ "style": {
+ "navigationBarTitleText": "搜索",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "product-detail",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "channel-detail",
+ "style": {
+ "navigationBarTitleText": "频道详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "shop-detail",
+ "style": {
+ "navigationBarTitleText": "店铺详情"
+ }
+ },
+ {
+ "path": "coupons",
+ "style": {
+ "navigationBarTitleText": "我的优惠券"
+ }
+ },
+ {
+ "path": "favorites",
+ "style": {
+ "navigationBarTitleText": "我的收藏"
+ }
+ },
+ {
+ "path": "footprint",
+ "style": {
+ "navigationBarTitleText": "我的足迹"
+ }
+ },
+ {
+ "path": "address",
+ "style": {
+ "navigationBarTitleText": "地址"
+ }
+ },
+ {
+ "path": "address-list",
+ "style": {
+ "navigationBarTitleText": "收货地址"
+ }
+ },
+ {
+ "path": "address-edit",
+ "style": {
+ "navigationBarTitleText": "编辑地址"
+ }
+ },
+ {
+ "path": "checkout",
+ "style": {
+ "navigationBarTitleText": "确认订单"
+ }
+ },
+ {
+ "path": "payment",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "payment-success",
+ "style": {
+ "navigationBarTitleText": "支付成功",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "orders",
+ "style": {
+ "navigationBarTitleText": "",
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": true,
+ "backgroundColor": "#f5f5f5"
+ }
+ },
+ {
+ "path": "order-detail",
+ "style": {
+ "navigationBarTitleText": "订单详情"
+ }
+ },
+ {
+ "path": "logistics",
+ "style": {
+ "navigationBarTitleText": "物流详情"
+ }
+ },
+ {
+ "path": "review",
+ "style": {
+ "navigationBarTitleText": "评价晒单"
+ }
+ },
+ {
+ "path": "refund",
+ "style": {
+ "navigationBarTitleText": "退款/售后"
+ }
+ },
+ {
+ "path": "apply-refund",
+ "style": {
+ "navigationBarTitleText": "申请售后"
+ }
+ },
+ {
+ "path": "refund-review",
+ "style": {
+ "navigationBarTitleText": "服务评价"
+ }
+ },
+ {
+ "path": "chat",
+ "style": {
+ "navigationBarTitleText": "客服聊天",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "chat_new",
+ "style": {
+ "navigationBarTitleText": "客服聊天(新版)"
+ }
+ },
+ {
+ "path": "subscription/plan-list",
+ "style": {
+ "navigationBarTitleText": "软件订阅"
+ }
+ },
+ {
+ "path": "subscription/plan-detail",
+ "style": {
+ "navigationBarTitleText": "订阅详情"
+ }
+ },
+ {
+ "path": "subscription/subscribe-checkout",
+ "style": {
+ "navigationBarTitleText": "确认订阅"
+ }
+ },
+ {
+ "path": "subscription/my-subscriptions",
+ "style": {
+ "navigationBarTitleText": "我的订阅"
+ }
+ },
+ {
+ "path": "subscription/followed-shops",
+ "style": {
+ "navigationBarTitleText": "关注店铺"
+ }
+ },
+ {
+ "path": "points/index",
+ "style": {
+ "navigationBarTitleText": "积分管理"
+ }
+ },
+ {
+ "path": "points/signin",
+ "style": {
+ "navigationBarTitleText": "签到"
+ }
+ },
+ {
+ "path": "points/exchange",
+ "style": {
+ "navigationBarTitleText": "积分兑换"
+ }
+ },
+ {
+ "path": "points/exchange-records",
+ "style": {
+ "navigationBarTitleText": "兑换记录"
+ }
+ },
+ {
+ "path": "red-packets/index",
+ "style": {
+ "navigationBarTitleText": "我的红包"
+ }
+ },
+ {
+ "path": "bank-cards/index",
+ "style": {
+ "navigationBarTitleText": "银行卡管理"
+ }
+ },
+ {
+ "path": "bank-cards/add",
+ "style": {
+ "navigationBarTitleText": "添加银行卡"
+ }
+ },
+ {
+ "path": "home-service/index",
+ "style": {
+ "navigationBarTitleText": "居家上门服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/apply",
+ "style": {
+ "navigationBarTitleText": "提交服务申请",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/service-detail",
+ "style": {
+ "navigationBarTitleText": "预约服务",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/order-detail",
+ "style": {
+ "navigationBarTitleText": "服务单详情",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "home-service/feedback",
+ "style": {
+ "navigationBarTitleText": "验收反馈",
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "bank-cards/verify",
+ "style": {
+ "navigationBarTitleText": "银行卡验证"
+ }
+ },
+ {
+ "path": "balance/index",
+ "style": {
+ "navigationBarTitleText": "余额"
+ }
+ },
+ {
+ "path": "my-reviews",
+ "style": {
+ "navigationBarTitleText": "我的评价"
+ }
+ },
+ {
+ "path": "message-detail",
+ "style": {
+ "navigationBarTitleText": "消息详情"
+ }
+ },
+ {
+ "path": "member/index",
+ "style": {
+ "navigationBarTitleText": "会员中心"
+ }
+ },
+ {
+ "path": "product-reviews",
+ "style": {
+ "navigationBarTitleText": "商品评价"
+ }
+ }
+ ]
+ }
+ ],
+ "tabBar": {
+ "color": "#999999",
+ "selectedColor": "#ff5000",
+ "backgroundColor": "#ffffff",
+ "borderStyle": "black",
+ "list": [
+ {
+ "pagePath": "pages/main/index",
+ "text": "首页",
+ "iconPath": "static/tabbar/home.png",
+ "selectedIconPath": "static/tabbar/home-active.png"
+ },
+ {
+ "pagePath": "pages/main/messages",
+ "text": "消息",
+ "iconPath": "static/tabbar/message.png",
+ "selectedIconPath": "static/tabbar/message.png"
+ },
+ {
+ "pagePath": "pages/main/cart",
+ "text": "购物车",
+ "iconPath": "static/tabbar/cart.png",
+ "selectedIconPath": "static/tabbar/cart.png"
+ },
+ {
+ "pagePath": "pages/main/profile",
+ "text": "我的",
+ "iconPath": "static/tabbar/user.png",
+ "selectedIconPath": "static/tabbar/user.png"
+ }
+ ]
+ },
+ "globalStyle": {
+ "navigationBarTextStyle": "black",
+ "navigationBarTitleText": "mall",
+ "navigationBarBackgroundColor": "#FFFFFF",
+ "backgroundColor": "#F8F8F8"
+ },
+ "condition": {
+ "current": 0,
+ "list": [
+ {
+ "name": "consumer端",
+ "path": "pages/main/index",
+ "query": "role=consumer"
+ }
+ ]
+ }
+}
diff --git a/docs/home-service/BACKEND_RPC_GAP_SUMMARY.md b/docs/home-service/BACKEND_RPC_GAP_SUMMARY.md
new file mode 100644
index 00000000..4df3f504
--- /dev/null
+++ b/docs/home-service/BACKEND_RPC_GAP_SUMMARY.md
@@ -0,0 +1,171 @@
+# 居家服务(Home Service)后端 RPC 缺口清单
+
+> 生成日期:2026-06-02
+> 适用项目:医疗-consumer / 医疗-delivery
+> 目标:替代前端直接写 `ec_*` / `hc_*` 新链表,所有状态变更必须由后端 RPC 驱动
+
+---
+
+## 1. 当前完成状态(前端侧)
+
+### 1.1 Delivery 履约动作 ✅ 已完成
+
+`api/delivery.uts` 中所有 delivery worker 动作已优先调用 `rpc_delivery_*` RPC,fallback 为本地 mock(不碰数据库)。
+
+| 前端动作 | RPC 名称 | 状态 |
+|---------|---------|------|
+| 接单 | `rpc_delivery_accept_order` | ✅ |
+| 拒单 | `rpc_delivery_reject_order` | ✅ |
+| 出发 | `rpc_delivery_start_depart` | ✅ |
+| 到达 | `rpc_delivery_arrive_order` | ✅ |
+| 签到 | `rpc_delivery_checkin_order` | ✅ |
+| 开始服务 | `rpc_delivery_start_service` | ✅ |
+| 保存进度 | `rpc_delivery_save_progress` | ✅ |
+| 上传留痕 | `rpc_delivery_upload_evidence` | ✅ |
+| 重试留痕 | `rpc_delivery_retry_evidence` | ✅ |
+| 提交异常 | `rpc_delivery_submit_exception` | ✅ |
+| 完成服务 | `rpc_delivery_finish_service` | ✅ |
+| 工单列表 | `rpc_delivery_order_list` | ✅ |
+| 工单详情 | `rpc_delivery_order_detail` | ✅ |
+| 仪表盘 | `rpc_delivery_dashboard` | ✅ |
+| 服务记录 | `rpc_delivery_record_list` | ✅ |
+| 消息列表 | `rpc_delivery_message_list` | ✅ |
+| 在线状态 | `rpc_delivery_set_online_status` | ✅ |
+
+### 1.2 Home Service 服务人员动作 ✅ 已完成
+
+`homeServiceService.uts` 中 worker 侧方法已切换为调用 delivery RPC:
+
+| 前端方法 | 调用 RPC | 状态 |
+|---------|---------|------|
+| `completeWorkerTask` | `rpc_delivery_finish_service` | ✅ |
+| `submitWorkerCheckIn` | `rpc_delivery_checkin_order` + `rpc_delivery_start_service` | ✅ |
+| `submitWorkerServiceRecord` | `rpc_delivery_save_progress` | ✅ |
+| `submitWorkerException` | `rpc_delivery_submit_exception` | ✅ |
+
+> 注:以上方法不再直接 `update ec_care_tasks` / `insert hc_work_order_events`。
+
+### 1.3 Consumer 验收动作 ⚠️ 已设安全门
+
+`serviceOrderService.uts` 中对 care task(UUID 格式 ID)已拒绝直接写表,返回提示:
+
+| 前端方法 | 旧行为 | 当前行为 |
+|---------|--------|---------|
+| `confirmServiceOrder` | 直接 `update ec_care_tasks` + `insert ec_care_records` | ❌ 拦截,提示“验收功能正在升级” |
+| `rejectServiceOrderAcceptance` | 直接 `update ec_care_tasks` + `insert hc_work_order_exceptions` | ❌ 拦截,提示“验收功能正在升级” |
+| `saveServiceRecord` | 直接 `insert ec_care_records` | ❌ 拦截,提示“服务记录功能正在升级” |
+| `dispatchPaidHomecareOrder` | 调用旧 `rpc_homecare_auto_dispatch` | ✅ 对 UUID 格式订单返回“同步中”安全提示;仅对 `so-` 前缀旧订单调用旧 RPC |
+
+### 1.4 Admin 管理动作 ⚠️ 已设安全门
+
+`homeServiceService.uts` 中 admin 方法不再直接写表,仅返回本地构建的 mock 详情:
+
+| 前端方法 | 状态 |
+|---------|------|
+| `submitAdminAssessment` | ⚠️ 返回本地构造的 assessment 对象,未写库 |
+| `submitAdminServicePlan` | ⚠️ 返回本地构造的 plan 对象,未写库 |
+| `submitAdminRectification` | ⚠️ 返回本地构造的 rectification 对象,未写库 |
+| `submitAdminSettlementArchive` | ⚠️ 操作本地 mock 数组 `ADMIN_SETTLEMENTS`,未写库 |
+
+### 1.5 工单创建 ⚠️ 过渡保留
+
+`tryCreateCareTask` 在 consumer 和 delivery 的 `serviceOrderService.uts` 中仍直接 `insert ec_service_requests` + `ec_care_tasks`,但已:
+- 移除前端自动派单逻辑(不再写入 `assigned_to` + `ORDER_ASSIGNED`)
+- 移除前端直接 `insert hc_work_order_events`
+- 增加 `ecServiceRequestCreateUnavailable` / `ecCareTaskCreateUnavailable` 开关,遇到表字段缺失时自动停用
+
+---
+
+## 2. 后端 RPC 缺口清单(按优先级)
+
+### 🔴 P0 — 阻断核心流程,必须补齐
+
+| 缺口编号 | RPC 名称 | 用途 | 调用方 | 当前替代方案 |
+|---------|---------|------|--------|-------------|
+| P0-1 | `rpc_consumer_create_homecare_task` | 消费者下单后创建/激活 `ec_service_requests` + `ec_care_tasks` | `serviceOrderService.uts` `tryCreateCareTask` | 前端直接 INSERT(过渡保留) |
+| P0-2 | `rpc_payment_callback_activate_care_task` | 支付成功后由后端自动/手动激活 care task | `payment.uvue` `dispatchPaidHomecareOrder` | 返回“同步中”提示,无实际激活 |
+
+> **说明**:P0-1 和 P0-2 解决的是“工单从哪里来”的问题。理想流程是:
+> 1. 消费者下单 → 写 `hss_service_orders`(旧交易链,保留)
+> 2. 支付成功 → 后端回调/RPC 创建 `ec_service_requests` + `ec_care_tasks`
+> 3. 后端调度系统派单 → `update ec_care_tasks` `assigned_to` + `ORDER_ASSIGNED`
+> 当前步骤 2/3 缺失,导致非套餐单走前端直接 INSERT。
+
+### 🟡 P1 — 影响用户体验,应尽快补齐
+
+| 缺口编号 | RPC 名称 | 用途 | 调用方 | 当前替代方案 |
+|---------|---------|------|--------|-------------|
+| P1-1 | `rpc_consumer_confirm_acceptance` | 消费者确认验收:`ec_care_tasks` → `ACCEPTED`,写入 `ec_care_records`(review) | `serviceOrderService.uts` `confirmServiceOrder` | 拦截,提示“正在升级” |
+| P1-2 | `rpc_consumer_reject_acceptance` | 消费者拒绝验收:`ec_care_tasks` → `ACCEPTANCE_REJECTED`,写入 `hc_work_order_exceptions` | `serviceOrderService.uts` `rejectServiceOrderAcceptance` | 拦截,提示“正在升级” |
+| P1-3 | `rpc_consumer_save_service_record` | 消费者/家属补充服务记录 | `serviceOrderService.uts` `saveServiceRecord` | 拦截,提示“正在升级” |
+
+### 🟢 P2 — 管理后台功能,可排期实现
+
+| 缺口编号 | RPC 名称 | 用途 | 调用方 | 当前替代方案 |
+|---------|---------|------|--------|-------------|
+| P2-1 | `rpc_admin_submit_assessment` | Admin 提交评估结果,写入评估表 + 事件 | `homeServiceService.uts` `submitAdminAssessment` | 仅返回本地对象,未持久化 |
+| P2-2 | `rpc_admin_submit_service_plan` | Admin 提交服务计划 | `homeServiceService.uts` `submitAdminServicePlan` | 仅返回本地对象,未持久化 |
+| P2-3 | `rpc_admin_submit_rectification` | Admin 提交整改结论 | `homeServiceService.uts` `submitAdminRectification` | 仅返回本地对象,未持久化 |
+| P2-4 | `rpc_admin_settlement_archive` | Admin 结算归档 | `homeServiceService.uts` `submitAdminSettlementArchive` | 仅操作本地 mock 数组 |
+
+---
+
+## 3. 前端已清理的直接写表现状
+
+### 3.1 已完全消除的前端直接写表
+
+以下前端直接写表行为已彻底移除:
+
+- ❌ `update ec_care_tasks`(由 delivery worker 动作触发)— 已全部改为 `rpc_delivery_*`
+- ❌ `insert hc_work_order_events`(由 worker/consumer 动作触发)— 已改为后端 RPC 统一写入
+- ❌ `insert ec_care_records`(由 worker checkin/service record 触发)— 已改为 `rpc_delivery_checkin_order` / `rpc_delivery_save_progress`
+- ❌ `insert hc_work_order_exceptions`(由 worker exception 触发)— 已改为 `rpc_delivery_submit_exception`
+- ❌ `insert hss_service_order_status_logs`(旧链)— `insertLegacyStatusLog` 已改为 NO-OP + console.warn
+
+### 3.2 仍保留的前端直接写表(仅限过渡)
+
+- ⚠️ `insert ec_service_requests` — `tryCreateCareTask`(consumer),有 `ecServiceRequestCreateUnavailable` 自动停用保护
+- ⚠️ `insert ec_care_tasks` — `tryCreateCareTask`(consumer + delivery),有字段缺失自动停用保护
+- ⚠️ `insert hss_service_orders` — 旧交易链路,用于套餐订单支付(预期中长期保留)
+- ⚠️ `update hss_service_orders` — `confirmServiceOrder` / `rejectServiceOrderAcceptance` 的 legacy 分支(仅对 `so-` 前缀订单)
+
+---
+
+## 4. 数据库表职责划分(当前约定)
+
+| 表名 | 职责 | 前端是否直接读写 |
+|------|------|----------------|
+| `ec_service_requests` | 新链服务申请 | ⚠️ 仅 `tryCreateCareTask` 过渡 INSERT,待 P0-1 RPC |
+| `ec_care_tasks` | 新链履约工单(核心状态) | ❌ 禁止前端直接 UPDATE,仅 SELECT 读详情/列表 |
+| `ec_care_records` | 服务记录(checkin、progress、review) | ❌ 禁止前端直接 INSERT/UPDATE |
+| `hc_work_order_events` | 状态事件/时间轴 | ❌ 禁止前端直接 INSERT |
+| `hc_work_order_exceptions` | 异常/整改记录 | ❌ 禁止前端直接 INSERT |
+| `hc_evidence_files` | 留痕文件 | ❌ 禁止前端直接 INSERT(走 `rpc_delivery_upload_evidence`) |
+| `hss_service_orders` | 旧交易链(套餐、支付) | ⚠️ 仍由前端直接 INSERT/SELECT(旧链路) |
+| `hss_service_assignments` | 旧派单链 | ❌ 前端不再直接写入 |
+| `hss_service_order_status_logs` | 旧状态日志 | ❌ 前端不再直接写入 |
+
+---
+
+## 5. 建议的后端实现顺序
+
+1. **P0-2** `rpc_payment_callback_activate_care_task`:在支付成功回调或支付页面调用,自动根据 `hss_service_orders` 创建 `ec_service_requests` + `ec_care_tasks`,并触发后端调度。
+2. **P0-1** `rpc_consumer_create_homecare_task`:如果需要在支付前创建工单(如非套餐单),提供此 RPC。
+3. **P1-1 / P1-2** 消费者验收 RPC:解除前端拦截,让用户可以正常完成验收流程。
+4. **P2-1 ~ P2-4** Admin 管理 RPC:补齐后台评估、计划、整改、归档功能。
+
+---
+
+## 6. 相关文件索引
+
+| 文件 | 说明 |
+|------|------|
+| `mall_sql/migrations/20260526_delivery_homecare_rpc_v1.sql` | 已实现的 delivery RPC( accept/reject/depart/arrive/checkin/start/save/finish/exception ) |
+| `mall_sql/migrations/20260601_hss_auto_dispatch_rpc_v2.sql` | 旧派单 RPC `rpc_homecare_auto_dispatch`(仅操作 `hss_*`,不应作为新链入口) |
+| `医疗-delivery/api/delivery.uts` | Delivery 端 API 层,已全量切换 RPC-first |
+| `医疗-consumer/api/delivery.uts` | Consumer 端 API 层,已全量切换 RPC-first |
+| `医疗-delivery/services/homeServiceService.uts` | Home Service 服务层,worker 动作已切 RPC,admin 动作已设安全门 |
+| `医疗-consumer/services/homeServiceService.uts` | 同上(consumer 端镜像) |
+| `医疗-consumer/services/serviceOrderService.uts` | Consumer 订单服务,care task 路径已设安全门,legacy 路径保留 |
+| `医疗-delivery/services/serviceOrderService.uts` | Delivery 订单服务,`tryCreateCareTask` 有过渡保护 |
+| `医疗-delivery/utils/homecare.constants.ts` | 统一状态常量 `ORDER_*` 与 `isCareTask()` 辅助函数 |
diff --git a/docs/home-service/FIX_service_detail_no_payment_page.md b/docs/home-service/FIX_service_detail_no_payment_page.md
new file mode 100644
index 00000000..3b10e362
--- /dev/null
+++ b/docs/home-service/FIX_service_detail_no_payment_page.md
@@ -0,0 +1,289 @@
+# 问题分析:service-detail.uvue 点击"提交预约"后未出现付款页面
+
+> 文件:`医疗-consumer/pages/mall/consumer/home-service/service-detail.uvue`
+> 关联文件:`order-detail.uvue`、`payment.uvue`、`serviceOrderService.uts`、`homeServiceService.uts`
+> 日期:2026-06-02
+
+---
+
+## 一、现象描述
+
+用户在 `service-detail.uvue`(服务单详情/预约页)填写完地址、时间、服务对象等信息后,点击底部"提交预约"按钮:
+- 页面提示"预约已提交"
+- 自动跳转到 `order-detail.uvue`(服务单详情页)
+- 订单状态显示为 **"待付款"**
+- **但用户从未进入过付款页面 (`payment.uvue`)**
+- 在 `order-detail.uvue` 中也**找不到任何"去付款"按钮**
+
+---
+
+## 二、根因分析(代码层面)
+
+### 2.1 问题点 ①:submitBooking 后直接跳 order-detail,未经过 payment
+
+**文件**:`service-detail.uvue` 第 933~1012 行
+
+```uts
+async function submitBooking() {
+ // ... 表单校验 ...
+ const created = await createHomeServiceApplication(draft)
+ if (created == null) {
+ uni.showToast({ title: '预约提交失败,请稍后重试', icon: 'none' })
+ return
+ }
+ uni.showToast({ title: '预约已提交', icon: 'success' })
+ // ❌ 直接跳转到订单详情,没有经过付款页
+ uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
+}
+```
+
+**预期行为**:预约提交成功后,应携带订单信息跳转到 `payment.uvue`,让用户完成支付。
+
+**实际行为**:直接 `uni.navigateTo` 到 `order-detail.uvue`,付款流程被完全跳过。
+
+---
+
+### 2.2 问题点 ②:order-detail.uvue 没有"去付款"按钮
+
+**文件**:`order-detail.uvue` 第 89~96 行
+
+```uts
+
+ 再次预约
+ 再次预约
+ 去验收反馈
+ 重新选择时间
+ 重新派单
+ 联系客服
+
+```
+
+**缺失**:当订单状态为 **"待付款"**(`paymentStatus == 1 && status == 'created'`)时,action row 没有任何"去付款"或"立即支付"的入口。
+
+用户一旦从预约页离开,再回到订单详情,也无法完成付款。
+
+---
+
+### 2.3 问题点 ③:createServiceOrder 的双轨逻辑
+
+**文件**:`serviceOrderService.uts` 第 1125~1179 行
+
+```uts
+export async function createServiceOrder(params: CreateServiceOrderParams): Promise {
+ // 非套餐单 → 走 ec_care_tasks 新链(前端直接 INSERT,无支付概念)
+ if (params.packageInfo.id == '') {
+ const newTask = await tryCreateCareTask(params)
+ if (newTask != null) {
+ return newTask
+ }
+ }
+ // 套餐单 → 走 hss_service_orders 旧链(有 payment_status 字段)
+ const response = await supa.from('hss_service_orders').insert({
+ // ... 包含 payable_amount、payment_status: 1 等字段
+ status: 'created',
+ payment_status: 1,
+ // ...
+ }).execute()
+}
+```
+
+**现状**:
+- 如果用户选择了套餐(`packageInfo.id != ''`),订单写入 `hss_service_orders`,`payment_status = 1`(未付款)
+- 如果用户没有选择套餐(`packageInfo.id == ''`),订单写入 `ec_care_tasks`,**没有支付概念**
+
+截图中的订单有价格(¥260)且显示"待付款",说明走的是 `hss_service_orders` 旧链。
+
+---
+
+### 2.4 问题点 ④:payment.uvue 的 source 推断对 UUID 不友好(潜在风险)
+
+**文件**:`payment.uvue` 第 1678~1683 行
+
+```uts
+if (source.value == '') {
+ // so- 前缀 → service,其他(包括 UUID)→ goods
+ source.value = orderId.value.startsWith('so-') ? 'service' : 'goods'
+}
+```
+
+**风险**:如果未来 `service-detail.uvue` 创建的是 `ec_care_tasks`(UUID 格式 id),跳转到 `payment.uvue` 时,`source` 会被误判为 `'goods'`,导致:
+- 支付成功后走商品发货流程,而非服务派单流程
+- `dispatchPaidHomecareOrder` 对 UUID 订单返回"同步中"提示,但 UI 可能显示商品订单的完成页
+
+---
+
+## 三、数据流全景图
+
+```
+用户填写预约信息
+ ↓
+点击【提交预约】
+ ↓
+submitBooking()
+ ↓
+createHomeServiceApplication(draft)
+ ↓
+createServiceOrder(params)
+ ↓
+┌─────────────────┬─────────────────┐
+│ packageInfo.id │ packageInfo.id │
+│ == '' │ != '' │
+│ (非套餐) │ (套餐) │
+└─────────────────┴─────────────────┘
+ ↓ ↓
+tryCreateCareTask insert hss_service_orders
+(insert ec_care_tasks) payment_status = 1 (未付款)
+ ↓ ↓
+ 无支付概念 有支付概念
+ ↓ ↓
+ 但都被直接 但也被直接
+ 跳 order-detail 跳 order-detail
+ ↓ ↓
+ ❌ 无付款入口 ❌ 无付款入口
+```
+
+---
+
+## 四、修复方案
+
+### 方案 A:提交预约后自动跳转付款页(最符合用户预期)
+
+**修改文件**:`service-detail.uvue` `submitBooking()`
+
+```uts
+async function submitBooking() {
+ // ... 原有校验逻辑保持不变 ...
+
+ const created = await createHomeServiceApplication(draft)
+ if (created == null) {
+ uni.showToast({ title: '预约提交失败,请稍后重试', icon: 'none' })
+ return
+ }
+
+ // 判断是否需要支付(仅 hss_service_orders 旧链有支付概念)
+ if (shouldUseCareTaskPath(created.id)) {
+ // ec_care_tasks 新链:无支付,直接到详情
+ uni.showToast({ title: '预约已提交', icon: 'success' })
+ uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
+ return
+ }
+
+ // hss_service_orders 旧链:需要支付,跳转到 payment.uvue
+ uni.showToast({ title: '预约成功,请完成支付', icon: 'success' })
+ uni.navigateTo({
+ url: '/pages/mall/consumer/payment?orderId=' + created.id
+ + '&source=service'
+ + '&bizType=service'
+ + '&amount=' + created.amount
+ // + '&orderNo=' + created.caseNo // 如需
+ })
+}
+```
+
+**注意事项**:
+- `shouldUseCareTaskPath(id)` 即 `isUuidLike(id)`,用于区分 ec_care_tasks(UUID)和 hss_service_orders(`so-` 前缀)
+- 如果 `created` 对象中没有 `amount`,需要从 `draft` 或 `selectedPackage` 中取 `price`
+
+---
+
+### 方案 B:order-detail.uvue 增加"去付款"按钮(兜底补偿)
+
+**修改文件**:`order-detail.uvue` action row
+
+```uts
+
+ 再次预约
+ 再次预约
+
+
+ 去付款
+ 去验收反馈
+ 重新选择时间
+ 重新派单
+ 联系客服
+
+```
+
+**新增方法**:
+
+```uts
+function goPayment() {
+ if (detail.value == null) {
+ return
+ }
+ uni.navigateTo({
+ url: '/pages/mall/consumer/payment?orderId=' + detail.value.id
+ + '&source=service'
+ + '&bizType=service'
+ + '&amount=' + detail.value.amount
+ })
+}
+```
+
+---
+
+### 方案 C(推荐组合):A + B 同时实施
+
+- **A 解决主流程**:用户预约后自然进入支付环节
+- **B 解决兜底场景**:用户中途退出、支付失败、从订单列表重新进入等情况下,仍能在详情页找到付款入口
+
+---
+
+## 五、与刚才完善的双轨逻辑如何配合
+
+### 5.1 当前已完善的逻辑
+
+1. **`dispatchPaidHomecareOrder`**(`serviceOrderService.uts`):
+ - 对 UUID 格式订单(ec_care_tasks):返回 `{ success: true, code: 'SYNC_IN_PROGRESS', message: '付款成功,服务安排信息正在同步中...' }`
+ - 对 `so-` 前缀订单(hss_service_orders):调用旧 `rpc_homecare_auto_dispatch`
+
+2. **`payment.uvue` 支付成功后的派单**:
+ - `source == 'service'` 时,调用 `dispatchPaidHomecareOrder`
+ - 成功则跳 `order-detail`
+ - 失败则显示重试弹窗
+
+### 5.2 修复后配合方式
+
+```
+用户提交预约(选择了套餐)
+ ↓
+createServiceOrder → 写入 hss_service_orders(payment_status=1)
+ ↓
+跳转 payment.uvue(携带 orderId + source=service + amount)
+ ↓
+用户完成支付
+ ↓
+payment.uvue 调用 dispatchPaidHomecareOrder(orderId)
+ ↓
+orderId 是 so- 前缀 → 调用 rpc_homecare_auto_dispatch(旧链派单)
+ ↓
+派单成功 → 跳转 order-detail.uvue
+ ↓
+用户看到"已派单"或"服务人员已接单"
+```
+
+**如果后端补齐了 `rpc_payment_callback_activate_care_task`**:
+- 可将 `createServiceOrder` 改为统一创建 `ec_care_tasks`
+- `payment.uvue` 支付成功后,调用新 RPC 激活 care task
+- 届时 `service-detail.uvue` 的 `submitBooking` 可统一跳 `payment.uvue`,无需区分 `so-` / UUID
+
+---
+
+## 六、需要修改的文件清单
+
+| 文件 | 修改内容 | 优先级 |
+|------|---------|--------|
+| `service-detail.uvue` | `submitBooking()` 成功后跳 `payment.uvue`(旧链订单) | P0 |
+| `order-detail.uvue` | action row 增加"待付款" → "去付款"按钮 | P0 |
+| `order-detail.uvue` | 新增 `goPayment()` 方法 | P0 |
+| `service-detail.uvue` | 确保 `created` 对象包含 `amount` / `price`,或从 draft 取 | P1 |
+
+---
+
+## 七、验证方式
+
+1. 进入 `service-detail.uvue`,选择套餐,填写信息,点击"提交预约"
+2. **预期**:跳转到 `payment.uvue`,显示订单金额、支付方式、倒计时
+3. 完成支付
+4. **预期**:自动派单,跳转到 `order-detail.uvue`,状态变为"已派单"或"待接单"
+5. 测试中途退出场景:在 `order-detail.uvue` 点击"去付款",应能重新进入 `payment.uvue`
diff --git a/docs/home-service/HOME_SERVICE_PACKAGE_PRICING_TEST_DATA_README.md b/docs/home-service/HOME_SERVICE_PACKAGE_PRICING_TEST_DATA_README.md
new file mode 100644
index 00000000..7f44efec
--- /dev/null
+++ b/docs/home-service/HOME_SERVICE_PACKAGE_PRICING_TEST_DATA_README.md
@@ -0,0 +1,87 @@
+# 居家服务套餐价格联调测试数据说明
+
+## 1. 为什么增加该批测试数据
+
+当前消费者端服务详情页的套餐价格来自页面内 mock,无法验证正式数据库查询路径、服务订单金额冻结、待付款金额回显与支付页展示的一致性。该批数据用于把联调切换到正式表结构和正式查询链路。
+
+## 2. 使用了哪些表
+
+- hss_service_catalog:现有服务目录表。
+- hss_service_packages:本次新增的正式套餐/报价表。
+- hss_service_orders:本次补充 service_package_id、pricing_snapshot_json、original_amount、payable_amount、total_amount 金额快照字段。
+
+## 3. Seed Batch 编号
+
+- DEV_HOME_SERVICE_PACKAGE_V1
+
+## 4. 哪些页面和接口依赖这些测试数据
+
+- pages/mall/consumer/home-service/service-detail.uvue
+- pages/mall/consumer/payment.uvue
+- pages/mall/consumer/payment-success.uvue
+- pages/mall/consumer/home-service/order-detail.uvue
+- services/homeServiceService.uts
+- services/serviceOrderService.uts
+- utils/supabaseService.uts
+
+## 5. 如何执行 seed
+
+执行顺序:
+
+1. 先执行 mall_sql/migrations/20260601_hss_service_package_pricing_v1.sql。
+2. 再执行 mall_sql/tests/dev_home_service_package_pricing_seed.sql。
+3. 执行后确认 hss_service_packages 中存在 data_source = dev_seed 且 seed_batch_no = DEV_HOME_SERVICE_PACKAGE_V1 的记录。
+
+建议校验 SQL:
+
+```sql
+SELECT id, service_id, package_name, duration_text, price, status, data_source, seed_batch_no
+FROM public.hss_service_packages
+WHERE seed_batch_no = 'DEV_HOME_SERVICE_PACKAGE_V1'
+ORDER BY service_id, sort_no;
+```
+
+## 6. 如何执行 cleanup
+
+执行 mall_sql/tests/dev_home_service_package_pricing_cleanup.sql。
+
+该脚本只会停用并软删除 DEV_HOME_SERVICE_PACKAGE_V1 批次的测试套餐,不会删除服务目录,也不会破坏已生成订单的金额快照。
+
+## 7. 为什么不能用于生产真实支付
+
+- 该批套餐明确标记为 dev_seed。
+- 套餐名称带有 测试 标记,仅用于开发联调。
+- 套餐价格不代表正式生产报价。
+- 若生产环境误读该数据,将导致真实支付金额错误。
+
+## 8. 上线前需要替换哪些数据
+
+- 导入正式 hss_service_packages 套餐数据。
+- 停用或清理 DEV_HOME_SERVICE_PACKAGE_V1。
+- 确认消费者查询链路不再返回 dev_seed 数据。
+- 确认新订单只冻结正式套餐价格。
+
+## 9. 数据清理失败时如何排查
+
+- 检查 hss_service_packages 是否已存在同批次记录但 remark 或 data_source 被手工改动。
+- 检查服务详情页是否仍然请求页面 mock 而不是正式查询接口。
+- 检查 hss_service_orders 是否已正确写入 pricing_snapshot_json 和 total_amount / payable_amount。
+- 检查消费者端是否继续从 URL amount 或 service_snapshot_json.price 回退展示金额。
+
+## 10. 负责人检查项
+
+- 已确认测试环境和正式环境的数据边界。
+- 已确认 DEV_HOME_SERVICE_PACKAGE_V1 只用于联调。
+- 已确认上线前有人执行 cleanup 或完成正式数据切换。
+- 已确认支付页金额来源是订单冻结快照,不是页面 mock 或 URL amount。
+
+## 11. 回滚方式
+
+- 若 migration 已执行但尚未接入前端,可只执行 cleanup 并停止读取 hss_service_packages。
+- 若需回退结构,请先确认没有正式或测试订单引用 service_package_id,再人工评估是否移除 hss_service_orders 新字段和 hss_service_packages 表。
+- 已生成订单时禁止直接删除订单金额快照字段。
+
+## 12. 统一上线提醒关键词
+
+- PROD_HOME_SERVICE_PRICING_SWITCH
+- DEV_HOME_SERVICE_PACKAGE_V1
diff --git a/docs/release/HOME_SERVICE_PACKAGE_PRICING_GOLIVE_CHECKLIST.md b/docs/release/HOME_SERVICE_PACKAGE_PRICING_GOLIVE_CHECKLIST.md
new file mode 100644
index 00000000..b2b9b95e
--- /dev/null
+++ b/docs/release/HOME_SERVICE_PACKAGE_PRICING_GOLIVE_CHECKLIST.md
@@ -0,0 +1,14 @@
+# 居家服务套餐价格上线检查清单
+
+- [ ] 已确认正式服务套餐 / 正式机构报价数据已导入
+- [ ] 已确认前端不再展示带标记的套餐
+- [ ] 已确认 DEV_HOME_SERVICE_PACKAGE_V1 数据已停用或清理
+- [ ] 已确认生产环境查询不会返回 dev_seed 测试套餐
+- [ ] 已确认真实支付订单金额来自正式数据库报价记录
+- [ ] 已确认 payment 页面金额不依赖 URL amount
+- [ ] 已确认服务订单金额快照与正式套餐报价一致
+- [ ] 已确认未付款服务订单不会进入派单
+- [ ] 已确认支付成功后才进入后续服务调度链路
+- [ ] 已完成商品订单与服务订单支付回归测试
+- [ ] 已全局搜索并处理 PROD_HOME_SERVICE_PRICING_SWITCH
+- [ ] 已确认不存在仍从 service_snapshot_json.price 直接推导新订单金额的代码路径
diff --git a/docs/sql/all_rls_merged.sql b/docs/sql/all_rls_merged.sql
new file mode 100644
index 00000000..20861bc9
--- /dev/null
+++ b/docs/sql/all_rls_merged.sql
@@ -0,0 +1,528 @@
+-- =====================================================================================
+-- RLS: 系统配置表安全策略
+-- 位置:docs/sql/20_rls/admin/ml_system_configs_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:允许所有登录用户读取配置;管理端全量操作通过 RPC (SECURITY DEFINER) 执行
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_system_configs ENABLE ROW LEVEL SECURITY;
+
+-- 1. 允许所有登录用户读取配置 (用于前端业务逻辑判断)
+DROP POLICY IF EXISTS system_configs_select_policy ON public.ml_system_configs;
+CREATE POLICY system_configs_select_policy ON public.ml_system_configs
+FOR SELECT TO authenticated USING (deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 权限管理 (Auth) 安全策略
+-- 位置:docs/sql/20_rls/auth/ak_auth_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:角色与权限表默认不对外开放,全量管理通过 SECURITY DEFINER RPC 执行
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_roles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_permissions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_admin_roles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_role_permissions ENABLE ROW LEVEL SECURITY;
+
+-- 默认策略:NO DIRECT ACCESS
+-- 所有的查询和修改均建议通过 docs/sql/30_rpc/auth/ 下的专用管理接口完成
+-- 这样可以确保鉴权逻辑与 ak_users.role 强制绑定,且具备审计能力
+-- =====================================================================================
+-- RLS: 内容管理模块安全策略
+-- 位置:docs/sql/20_rls/cms/ml_cms_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端可读(仅已发布/启用);管理端通过 RPC 访问
+-- =====================================================================================
+
+-- 1. 开启 RLS
+ALTER TABLE public.ml_article_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_articles ENABLE ROW LEVEL SECURITY;
+
+-- 2. 分类表策略:允许所有人读取启用的分类
+DROP POLICY IF EXISTS ml_article_categories_select_active ON public.ml_article_categories;
+CREATE POLICY ml_article_categories_select_active
+ ON public.ml_article_categories
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 3. 文章表策略:允许所有人读取已发布的文章
+DROP POLICY IF EXISTS ml_articles_select_published ON public.ml_articles;
+CREATE POLICY ml_articles_select_published
+ ON public.ml_articles
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,管理端操作通过 RPC (SECURITY DEFINER) 执行
+-- =====================================================================================
+-- RLS: 装修模块 - DIY 页面安全策略
+-- 位置:docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端公开只读已发布的页面;管理端通过 SECURITY DEFINER RPC 进行管理
+-- =====================================================================================
+
+-- 1. 启用 RLS
+ALTER TABLE public.ak_diy_pages ENABLE ROW LEVEL SECURITY;
+
+-- 2. 消费者端策略:允许匿名和登录用户读取已启用的页面
+DROP POLICY IF EXISTS diy_pages_select_active ON public.ak_diy_pages;
+CREATE POLICY diy_pages_select_active ON public.ak_diy_pages
+FOR SELECT TO anon, authenticated
+USING (is_active = true AND deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 物流设置 (Delivery) 安全策略
+-- 位置:docs/sql/20_rls/delivery/ak_delivery_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:配送员表管理端私有;提货点表消费者端只读
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_delivery_staff ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_delivery_stations ENABLE ROW LEVEL SECURITY;
+
+-- 1. 配送员表策略:默认不开放直接访问
+-- 全量管理通过 docs/sql/30_rpc/delivery/ 下的 RPC 执行
+
+-- 2. 提货点表策略:允许消费者端只读(用于地图展示和下单选择)
+DROP POLICY IF EXISTS delivery_stations_select_active ON public.ml_delivery_stations;
+CREATE POLICY delivery_stations_select_active
+ ON public.ml_delivery_stations
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行
+-- =====================================================================================
+-- RLS: 医养执行端 Delivery 安全策略升级
+-- 位置:docs/sql/20_rls/delivery/ak_delivery_rls_v2.sql
+-- 对象类型:RLS 策略
+-- 版本:v2
+-- 说明:保留管理端通过 SECURITY DEFINER RPC 管理,补充执行人员本人直读自己档案。
+-- =====================================================================================
+
+ALTER TABLE public.ml_delivery_staff ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_delivery_stations ENABLE ROW LEVEL SECURITY;
+
+-- 清理旧策略
+DROP POLICY IF EXISTS delivery_staff_self_select ON public.ml_delivery_staff;
+DROP POLICY IF EXISTS delivery_staff_self_update ON public.ml_delivery_staff;
+DROP POLICY IF EXISTS delivery_stations_select_active ON public.ml_delivery_stations;
+
+-- 1. 执行人员本人可直读自己的未删除档案
+CREATE POLICY delivery_staff_self_select
+ ON public.ml_delivery_staff
+ FOR SELECT
+ TO authenticated
+ USING (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ );
+
+-- 2. 执行人员本人可更新自己的在线状态等自有档案字段
+CREATE POLICY delivery_staff_self_update
+ ON public.ml_delivery_staff
+ FOR UPDATE
+ TO authenticated
+ USING (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ );
+
+-- 3. 提货点/机构对前台保持只读,仅返回启用且未删除数据
+CREATE POLICY delivery_stations_select_active
+ ON public.ml_delivery_stations
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 4. 派单候选人读取统一走 SECURITY DEFINER RPC,不再开放公开可派单列表 RLS
+-- 5. 其余直连写操作默认不开放,管理端统一走 SECURITY DEFINER RPC
+-- =====================================================================================
+-- RLS: 分销模块安全策略
+-- 位置:docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量权限通过 SECURITY DEFINER RPC 执行;用户仅能访问个人关联数据
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_distribution_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_level ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_promoter_relations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_commission_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_divisions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_agents ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_agent_applications ENABLE ROW LEVEL SECURITY;
+
+-- 1. 分销配置:允许所有登录用户读取(消费者端展示逻辑需要)
+DROP POLICY IF EXISTS dist_config_select_policy ON public.ak_distribution_config;
+CREATE POLICY dist_config_select_policy ON public.ak_distribution_config
+FOR SELECT TO authenticated USING (deleted_at IS NULL);
+
+-- 2. 分销等级:允许所有登录用户读取可见等级
+DROP POLICY IF EXISTS dist_level_select_policy ON public.ak_distribution_level;
+CREATE POLICY dist_level_select_policy ON public.ak_distribution_level
+FOR SELECT TO authenticated USING (is_visible = true AND deleted_at IS NULL);
+
+-- 3. 推广员关系:用户仅能查看与自己相关的记录
+DROP POLICY IF EXISTS promoter_relations_select_policy ON public.ak_promoter_relations;
+CREATE POLICY promoter_relations_select_policy ON public.ak_promoter_relations
+FOR SELECT TO authenticated USING ((uid = auth.uid() OR inviter_uid = auth.uid()) AND deleted_at IS NULL);
+
+-- 4. 佣金日志:用户仅能查看自己的佣金记录
+DROP POLICY IF EXISTS commission_logs_select_policy ON public.ak_commission_logs;
+CREATE POLICY commission_logs_select_policy ON public.ak_commission_logs
+FOR SELECT TO authenticated USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 5. 事业部与代理商:允许登录用户查看启用的记录
+DROP POLICY IF EXISTS dist_divisions_select_policy ON public.ak_distribution_divisions;
+CREATE POLICY dist_divisions_select_policy ON public.ak_distribution_divisions
+FOR SELECT TO authenticated USING (is_enabled = true AND deleted_at IS NULL);
+
+DROP POLICY IF EXISTS dist_agents_select_policy ON public.ak_distribution_agents;
+CREATE POLICY dist_agents_select_policy ON public.ak_distribution_agents
+FOR SELECT TO authenticated USING (is_enabled = true AND deleted_at IS NULL);
+
+-- 6. 代理商申请:用户仅能管理自己的申请记录
+DROP POLICY IF EXISTS dist_apply_user_policy ON public.ak_distribution_agent_applications;
+CREATE POLICY dist_apply_user_policy ON public.ak_distribution_agent_applications
+FOR ALL TO authenticated USING (uid = auth.uid() AND deleted_at IS NULL) WITH CHECK (uid = auth.uid());
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 用户提现申请表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的提现记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_extract ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的提现申请
+DROP POLICY IF EXISTS ml_extract_user_select ON public.ml_extract;
+CREATE POLICY ml_extract_user_select
+ ON public.ml_extract
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户
+-- 提现申请通常由特定的 RPC 函数 (security definer) 创建,以确保业务逻辑(如冻结余额)的原子性
+-- =====================================================================================
+-- RLS: 发票管理表
+-- 位置:docs/sql/20_rls/finance/ml_invoices_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:用户仅能查看自己的开票申请;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_invoices ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_invoices_user_select ON public.ml_invoices;
+CREATE POLICY ml_invoices_user_select
+ ON public.ml_invoices
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,通常由 RPC 或支付后逻辑触发
+-- =====================================================================================
+-- RLS: 用户资金流水表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的流水记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_user_bill ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_user_bill_user_select ON public.ml_user_bill;
+CREATE POLICY ml_user_bill_user_select
+ ON public.ml_user_bill
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,由后端逻辑或 RPC 触发
+-- =====================================================================================
+-- RLS: 用户充值记录表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的充值记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_user_recharge ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_user_recharge_user_select ON public.ml_user_recharge;
+CREATE POLICY ml_user_recharge_user_select
+ ON public.ml_user_recharge
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,写操作通常由业务逻辑或支付回调触发
+-- =====================================================================================
+-- RLS: 客服模块安全策略
+-- 位置:docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;用户仅能操作自己的留言反馈
+-- =====================================================================================
+
+-- 开启所有表的 RLS
+ALTER TABLE public.ml_kefu_accounts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_word_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_words ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_feedbacks ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_auto_replies ENABLE ROW LEVEL SECURITY;
+
+-- 1. 留言反馈表策略
+-- 允许登录用户插入自己的留言
+DROP POLICY IF EXISTS ml_kefu_feedbacks_user_insert ON public.ml_kefu_feedbacks;
+CREATE POLICY ml_kefu_feedbacks_user_insert
+ ON public.ml_kefu_feedbacks
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (user_id = auth.uid());
+
+-- 允许用户查看自己的留言
+DROP POLICY IF EXISTS ml_kefu_feedbacks_user_select ON public.ml_kefu_feedbacks;
+CREATE POLICY ml_kefu_feedbacks_user_select
+ ON public.ml_kefu_feedbacks
+ FOR SELECT
+ TO authenticated
+ USING (user_id = auth.uid());
+
+-- 其他表(账号、话术、自动回复)默认不向 anon/authenticated 角色开放 SELECT/INSERT/UPDATE/DELETE
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 函数执行
+-- =====================================================================================
+-- RLS Policy: 优惠券模板表权限控制
+-- 位置:docs/sql/20_rls/marketing/ml_coupon_templates_rls_v1.sql
+-- 说明:确保商家仅能管理自己的优惠券模板,管理员拥有全权限。
+-- =====================================================================================
+
+-- 1. 启用 RLS
+ALTER TABLE public.ml_coupon_templates ENABLE ROW LEVEL SECURITY;
+
+-- 2. 创建权限策略
+-- 允许商家管理自己的模板
+CREATE POLICY ml_coupon_templates_merchant_policy ON public.ml_coupon_templates
+ FOR ALL
+ TO authenticated
+ USING (
+ merchant_id = auth.uid() OR
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+ )
+ WITH CHECK (
+ merchant_id = auth.uid() OR
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+ );
+
+-- 允许所有认证用户查看模板(用于前台领取)
+CREATE POLICY ml_coupon_templates_select_policy ON public.ml_coupon_templates
+ FOR SELECT
+ TO authenticated
+ USING (status = 1);
+-- =====================================================================================
+-- RLS: 营销核心活动表安全策略
+-- 位置:docs/sql/20_rls/marketing/ml_marketing_activities_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端公开只读;管理端操作由 RPC (SECURITY DEFINER) 承载
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_seckill_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_combination_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_bargains ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_groupbuys ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_lotteries ENABLE ROW LEVEL SECURITY;
+
+-- 1. 秒杀活动:公开只读
+DROP POLICY IF EXISTS seckill_select_policy ON public.ak_seckill_activities;
+CREATE POLICY seckill_select_policy ON public.ak_seckill_activities FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 2. 拼团活动:公开只读
+DROP POLICY IF EXISTS combination_select_policy ON public.ak_combination_activities;
+CREATE POLICY combination_select_policy ON public.ak_combination_activities FOR SELECT TO anon, authenticated USING (status = 'ongoing');
+
+-- 3. 砍价活动:公开只读
+DROP POLICY IF EXISTS bargain_select_policy ON public.ak_marketing_bargains;
+CREATE POLICY bargain_select_policy ON public.ak_marketing_bargains FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 4. 团购活动:公开只读
+DROP POLICY IF EXISTS groupbuy_select_policy ON public.ak_marketing_groupbuys;
+CREATE POLICY groupbuy_select_policy ON public.ak_marketing_groupbuys FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 5. 抽奖活动:公开只读
+DROP POLICY IF EXISTS lottery_select_policy ON public.ak_marketing_lotteries;
+CREATE POLICY lottery_select_policy ON public.ak_marketing_lotteries FOR SELECT TO anon, authenticated USING (is_open = true);
+
+-- 管理端全量管理将由 SECURITY DEFINER 的 RPC 接口执行
+-- =====================================================================================
+-- RLS: 营销模块其他业务表安全策略 (互动/会员/直播/充值)
+-- 位置:docs/sql/20_rls/marketing/ml_marketing_others_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:配置类公开只读;记录类用户隔离;管理端由 RPC 承载
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_signin_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_signin_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_newcomer_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_types ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_rights ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_anchors ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_rooms ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_quotas ENABLE ROW LEVEL SECURITY;
+
+-- 1. 签到与新人礼配置:公开只读
+DROP POLICY IF EXISTS signin_config_select_policy ON public.ak_signin_configs;
+CREATE POLICY signin_config_select_policy ON public.ak_signin_configs FOR SELECT TO authenticated USING (true);
+
+DROP POLICY IF EXISTS newcomer_config_select_policy ON public.ak_marketing_newcomer_config;
+CREATE POLICY newcomer_config_select_policy ON public.ak_marketing_newcomer_config FOR SELECT TO authenticated USING (true);
+
+-- 2. 签到日志:用户仅能查看自己的
+DROP POLICY IF EXISTS signin_logs_user_policy ON public.ak_marketing_signin_logs;
+CREATE POLICY signin_logs_user_policy ON public.ak_marketing_signin_logs
+FOR SELECT TO authenticated USING (uid = auth.uid());
+
+-- 3. 会员体系:类型与权益公开只读
+DROP POLICY IF EXISTS member_types_select_policy ON public.ak_marketing_member_types;
+CREATE POLICY member_types_select_policy ON public.ak_marketing_member_types FOR SELECT TO authenticated USING (is_open = true);
+
+DROP POLICY IF EXISTS member_rights_select_policy ON public.ak_marketing_member_rights;
+CREATE POLICY member_rights_select_policy ON public.ak_marketing_member_rights FOR SELECT TO authenticated USING (is_show = true);
+
+DROP POLICY IF EXISTS member_config_select_policy ON public.ak_marketing_member_config;
+CREATE POLICY member_config_select_policy ON public.ak_marketing_member_config FOR SELECT TO authenticated USING (is_enabled = true);
+
+-- 4. 直播:公开只读
+DROP POLICY IF EXISTS live_anchors_select_policy ON public.ak_marketing_live_anchors;
+CREATE POLICY live_anchors_select_policy ON public.ak_marketing_live_anchors FOR SELECT TO authenticated USING (status = true);
+
+DROP POLICY IF EXISTS live_rooms_select_policy ON public.ak_marketing_live_rooms;
+CREATE POLICY live_rooms_select_policy ON public.ak_marketing_live_rooms FOR SELECT TO authenticated USING (is_show = true);
+
+-- 5. 充值配置:公开只读
+DROP POLICY IF EXISTS recharge_config_select_policy ON public.ak_recharge_configs;
+CREATE POLICY recharge_config_select_policy ON public.ak_recharge_configs FOR SELECT TO authenticated USING (balance_enabled = true);
+
+DROP POLICY IF EXISTS recharge_quotas_select_policy ON public.ak_recharge_quotas;
+CREATE POLICY recharge_quotas_select_policy ON public.ak_recharge_quotas FOR SELECT TO authenticated USING (is_open = true);
+
+-- 管理端全量管理均通过 SECURITY DEFINER 的 RPC 接口执行
+-- RLS: 医疗商城独立分类与商品分类关联
+-- 执行阶段: 20_rls
+
+ALTER TABLE public.medical_mall_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.medical_mall_product_categories ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS medical_mall_categories_public_select_active ON public.medical_mall_categories;
+CREATE POLICY medical_mall_categories_public_select_active
+ON public.medical_mall_categories
+FOR SELECT
+TO anon, authenticated
+USING (
+ deleted_at IS NULL
+ AND is_active = TRUE
+ AND compliance_type <> 'rx_hidden'
+);
+
+DROP POLICY IF EXISTS medical_mall_product_categories_public_select_active ON public.medical_mall_product_categories;
+CREATE POLICY medical_mall_product_categories_public_select_active
+ON public.medical_mall_product_categories
+FOR SELECT
+TO anon, authenticated
+USING (deleted_at IS NULL);-- =====================================================================================
+-- RLS: 用户分组表
+-- 位置:docs/sql/20_rls/user/ak_user_groups_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;消费者端默认不开放直接访问
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_groups ENABLE ROW LEVEL SECURITY;
+
+-- 如果未来消费者端需要展示所在分组,可以在此添加对应的 SELECT 策略
+-- 目前默认不向普通用户开放任何直接 SQL 读写权限
+-- =====================================================================================
+-- RLS: 用户标签表
+-- 位置:docs/sql/20_rls/user/ak_user_labels_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;消费者端默认不开放直接访问
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_labels ENABLE ROW LEVEL SECURITY;
+
+-- 若后续消费者端需要展示标签,可在此添加 SELECT 策略
+-- 当前默认不向普通用户开放任何直接 SQL 读写权限
+-- =====================================================================================
+-- RLS: 用户等级表
+-- 位置:docs/sql/20_rls/user/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端可读(仅可见/启用/未删除);管理端全量访问通过 RPC 完成
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_levels ENABLE ROW LEVEL SECURITY;
+
+-- 消费者端:允许读取可见且启用的等级(未删除)
+DROP POLICY IF EXISTS ak_user_levels_public_select_visible_active ON public.ak_user_levels;
+CREATE POLICY ak_user_levels_public_select_visible_active
+ ON public.ak_user_levels
+ FOR SELECT
+ TO anon, authenticated
+ USING (
+ deleted_at IS NULL
+ AND status = 1
+ AND is_visible = TRUE
+ );
+
+-- 默认不开放写权限(INSERT/UPDATE/DELETE)给 anon/authenticated
diff --git a/docs/sql/all_rpc_merged.sql b/docs/sql/all_rpc_merged.sql
new file mode 100644
index 00000000..0a2235fc
--- /dev/null
+++ b/docs/sql/all_rpc_merged.sql
@@ -0,0 +1,6169 @@
+-- =====================================================================================
+-- Admin 统计功能 - 获取全站核心指标概览 RPC
+-- 位置:docs/sql/30_rpc/admin/rpc_admin_get_overall_stats_v1.sql
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:一次性聚合查询销售、订单、用户及商品的核心统计指标
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_overall_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_stats JSONB;
+ v_today_start TIMESTAMPTZ := CURRENT_DATE;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 聚合统计
+ WITH totals AS (
+ SELECT
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1) as total_sales,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1) as total_orders,
+ (SELECT COUNT(*) FROM public.ak_users) as total_users,
+ (SELECT COUNT(*) FROM public.ml_products) as total_products
+ ),
+ today_stats AS (
+ SELECT
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_sales,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_orders,
+ (SELECT COUNT(*) FROM public.ak_users WHERE created_at >= v_today_start) as today_new_users
+ ),
+ pending_tasks AS (
+ SELECT
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND order_status = 1) as pending_delivery,
+ (SELECT COUNT(*) FROM public.ml_product_skus WHERE stock <= 10) as stock_warning, -- 假设库存小于10为预警
+ (SELECT COUNT(*) FROM public.ml_extract WHERE status = 0) as pending_extract
+ )
+ SELECT jsonb_build_object(
+ 'totals', (SELECT row_to_json(totals.*) FROM totals),
+ 'today', (SELECT row_to_json(today_stats.*) FROM today_stats),
+ 'pending', (SELECT row_to_json(pending_tasks.*) FROM pending_tasks)
+ ) INTO v_stats;
+
+ RETURN v_stats;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_overall_stats() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_overall_stats() TO authenticated;
+-- =====================================================================================
+-- Admin 系统维护 - 获取服务器环境信息 RPC
+-- 位置:docs/sql/30_rpc/admin/rpc_admin_get_system_info_v1.sql
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取服务器操作系统、数据库版本及运行环境信息
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_system_info()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_info JSONB;
+ v_db_version TEXT;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取数据库版本
+ SELECT version() INTO v_db_version;
+
+ -- 3. 构建返回信息
+ v_info := jsonb_build_object(
+ 'server_os', 'Linux (Simulated)', -- 数据库侧通常难以直接获取完整的宿主系统信息
+ 'web_server', 'Nginx/1.24.0 (Simulated)',
+ 'db_engine', 'PostgreSQL',
+ 'db_version', v_db_version,
+ 'uts_runtime', 'uni-app x (UTS)',
+ 'auth_id', 'ZC2884891' -- 模拟授权码
+ );
+
+ RETURN v_info;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_system_info() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_system_info() TO authenticated;
+-- =====================================================================================
+-- Admin 系统功能 - 获取配置项 RPC
+-- 位置:docs/sql/30_rpc/admin/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_system_configs, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_get(
+ p_key TEXT
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_value JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取配置值
+ SELECT config_value INTO v_value
+ FROM public.ml_system_configs
+ WHERE config_key = p_key;
+
+ RETURN v_value;
+END;
+$$;-- =====================================================================================
+-- Admin 系统功能 - 保存/更新配置项 RPC
+-- 位置:docs/sql/30_rpc/admin/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_system_configs, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_save(
+ p_key TEXT,
+ p_value JSONB,
+ p_description TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 插入或更新配置
+ INSERT INTO public.ml_system_configs (config_key, config_value, description, updated_at)
+ VALUES (p_key, p_value, p_description, NOW())
+ ON CONFLICT (config_key) DO UPDATE
+ SET
+ config_value = EXCLUDED.config_value,
+ description = COALESCE(EXCLUDED.description, public.ml_system_configs.description),
+ updated_at = NOW();
+
+ RETURN TRUE;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_analytics_user_gender_distribution
+-- Version: v1
+-- Purpose: 统计指定周期内新增用户的性别分布(用于 Admin/Analytics 图表)
+-- Security: SECURITY DEFINER + 固定 search_path + 入口角色鉴权
+-- Depends: public.ak_users, public.get_current_user_role()
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_analytics_user_gender_distribution(
+ p_start_date DATE,
+ p_end_date DATE
+)
+RETURNS TABLE (
+ name TEXT,
+ value BIGINT
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ IF public.get_current_user_role() NOT IN ('admin', 'analytics') THEN
+ RAISE EXCEPTION 'Permission denied: required role admin or analytics';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ CASE
+ WHEN gender IS NULL OR TRIM(gender::text) = '' THEN '未知'
+ WHEN LOWER(TRIM(gender::text)) = 'male' THEN '男'
+ WHEN LOWER(TRIM(gender::text)) = 'female' THEN '女'
+ WHEN LOWER(TRIM(gender::text)) = 'other' THEN '未知'
+ ELSE '未知'
+ END AS name,
+ COUNT(*)::BIGINT AS value
+ FROM public.ak_users
+ WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
+ GROUP BY 1
+ ORDER BY value DESC;
+END;
+$$;
+-- =====================================================================================
+-- 函数: check_admin_permission
+-- 描述: 通用的 RBAC 权限校验函数
+-- 参数: p_permission_code - 权限编码 (如 'role:delete', 'user:view')
+-- 返回: BOOLEAN
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.check_admin_permission(
+ p_permission_code TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID;
+ v_role TEXT;
+BEGIN
+ -- 1. 获取当前登录用户的 Profile ID 和角色
+ SELECT id, role INTO v_user_id, v_role
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 2. 未登录或未找到 Profile
+ IF v_user_id IS NULL THEN
+ RETURN FALSE;
+ END IF;
+
+ -- 3. 超级管理员拥有所有权限 (保持向下兼容)
+ IF v_role = 'admin' THEN
+ RETURN TRUE;
+ END IF;
+
+ -- 4. 如果指定了权限编码,则检查 ak_permissions 体系
+ IF p_permission_code IS NOT NULL THEN
+ RETURN EXISTS (
+ SELECT 1
+ FROM public.ak_admin_roles ar
+ JOIN public.ak_role_permissions rp ON ar.role_id = rp.role_id
+ JOIN public.ak_permissions p ON rp.permission_id = p.id
+ WHERE ar.admin_id = v_user_id
+ AND p.code = p_permission_code
+ AND p.deleted_at IS NULL
+ AND ar.deleted_at IS NULL
+ );
+ END IF;
+
+ RETURN FALSE;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.check_admin_permission(TEXT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.check_admin_permission(TEXT) TO authenticated;
+-- =====================================================================================
+-- RPC: get_current_user_role
+-- Version: v1
+-- Purpose: 获取当前登录用户的角色(用于 RPC 入口鉴权)
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends: public.ak_users (auth_id, role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.get_current_user_role()
+RETURNS TEXT
+LANGUAGE sql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT role
+ FROM public.ak_users
+ WHERE auth_id = auth.uid()
+ LIMIT 1;
+$$;-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v2
+-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威用户表)并保持 user_roles 兼容写入
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role,created_by) (如存在)
+-- Notes:
+-- - 角色权威口径为 public.ak_users.role
+-- - user_roles 为历史/兼容表:存在则写入,不存在则跳过
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT := 'customer';
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ has_user_roles BOOLEAN := FALSE;
+BEGIN
+ -- 1) 基于邮箱规则分配默认角色(可按需调整)
+ IF user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN
+ user_role := 'teacher';
+ ELSIF user_email ILIKE '%@admin.%' THEN
+ user_role := 'admin';
+ END IF;
+
+ -- 2) 默认 username:取邮箱 @ 前缀
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user';
+ END IF;
+
+ -- 3) 写入 ak_users(权威)
+ -- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ role = COALESCE(public.ak_users.role, EXCLUDED.role),
+ updated_at = now();
+
+ -- 4) 兼容写入 user_roles(如果表存在)
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema='public'
+ AND table_name='user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, user_role, NEW.id);
+ EXCEPTION WHEN unique_violation THEN
+ -- 忽略重复
+ NULL;
+ END;
+ END IF;
+
+ -- 5) 更新 auth.users 元数据(可选保留)
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;
+-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v3
+-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威)和 public.user_roles(兼容)。
+-- 此版本修复了向 user_roles 写入时可能因 role 为 NULL 导致的 NOT NULL 约束失败问题。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT;
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ has_user_roles BOOLEAN := FALSE;
+BEGIN
+ -- 1) 基于邮箱规则分配默认角色(可按需调整)
+ -- 确保 user_role 总有一个非 NULL 的值
+ user_role := CASE
+ WHEN user_email ILIKE '%@admin.%' THEN 'admin'
+ WHEN user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN 'teacher'
+ ELSE 'consumer' -- 默认角色
+ END;
+
+ -- 2) 默认 username:取邮箱 @ 前缀
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user_' || SUBSTRING(NEW.id::text, 1, 8); -- 使用 user_ + uid前8位作为备用名
+ END IF;
+
+ -- 3) 写入 ak_users(权威)
+ -- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ -- 只有当现有 role 为空时才更新,避免覆盖手动设置的 admin 角色
+ role = COALESCE(public.ak_users.role, EXCLUDED.role),
+ updated_at = now();
+
+ -- 4) 兼容写入 user_roles(如果表存在)
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema='public'
+ AND table_name='user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ -- 确保插入的 role 不为 NULL,即使上面的逻辑有误
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, COALESCE(user_role, 'customer'), NEW.id);
+ EXCEPTION
+ WHEN unique_violation THEN
+ -- 忽略重复插入的错误
+ NULL;
+ WHEN not_null_violation THEN
+ -- 记录非空约束错误,但不中断整个触发器
+ RAISE NOTICE '[handle_new_user] WARNING: Failed to INSERT into user_roles due to NOT NULL violation. user_id: %, role: %', NEW.id, user_role;
+ END;
+ END IF;
+
+ -- 5) 更新 auth.users 元数据(可选保留)
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v4
+-- Purpose: auth.users 新用户创建后,优先读取 raw_user_meta_data.user_role 写入 ak_users.role。
+-- 解决 delivery / merchant 注册时被错误降级为默认 consumer 的问题。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT;
+ metadata_role TEXT;
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ ak_user_id UUID;
+ has_user_roles BOOLEAN := FALSE;
+ has_delivery_staff BOOLEAN := FALSE;
+BEGIN
+ metadata_role := NULLIF(TRIM(COALESCE(NEW.raw_user_meta_data ->> 'user_role', '')), '');
+
+ user_role := CASE
+ WHEN metadata_role IN ('customer', 'merchant', 'delivery', 'service', 'admin') THEN metadata_role
+ WHEN user_email ILIKE '%@admin.%' THEN 'admin'
+ WHEN user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN 'customer'
+ ELSE 'customer'
+ END;
+
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user_' || SUBSTRING(NEW.id::text, 1, 8);
+ END IF;
+
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ role = COALESCE(NULLIF(public.ak_users.role, ''), EXCLUDED.role),
+ updated_at = now()
+ RETURNING id INTO ak_user_id;
+
+ IF user_role = 'delivery' THEN
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'ml_delivery_staff'
+ ) INTO has_delivery_staff;
+
+ IF has_delivery_staff THEN
+ INSERT INTO public.ml_delivery_staff (
+ uid,
+ nickname,
+ phone,
+ status,
+ is_active
+ )
+ SELECT
+ ak_user_id,
+ user_name,
+ '',
+ 1,
+ TRUE
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff
+ WHERE uid = ak_user_id
+ );
+ END IF;
+ END IF;
+
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, user_role, NEW.id)
+ ON CONFLICT DO NOTHING;
+ EXCEPTION
+ WHEN check_violation THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Skipped user_roles insert due to check violation. user_id: %, role: %', NEW.id, user_role;
+ WHEN not_null_violation THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Failed to INSERT into user_roles due to NOT NULL violation. user_id: %, role: %', NEW.id, user_role;
+ WHEN others THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Skipped user_roles insert due to unexpected error. user_id: %, role: %, err: %', NEW.id, user_role, SQLERRM;
+ END;
+ END IF;
+
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;-- RPC: rpc_admin_delete_permission
+-- 管理端删除功能权限/菜单(支持级联软删除关联的角色权限映射)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_permission(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数,权限编码: permission:delete)
+ IF NOT public.check_admin_permission('permission:delete') THEN
+ RAISE EXCEPTION 'Permission denied: permission:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先删除所有关联了该权限的角色映射
+ UPDATE public.ak_role_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE permission_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 最后软删除权限本身
+ UPDATE public.ak_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_permission(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_permission(UUID) TO authenticated;
+-- RPC: rpc_admin_delete_role
+-- 管理端删除角色(支持级联软删除关联权限)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_role(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数,权限编码: role:delete)
+ IF NOT public.check_admin_permission('role:delete') THEN
+ RAISE EXCEPTION 'Permission denied: role:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先删除该角色下的所有权限关联
+ UPDATE public.ak_role_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE role_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 级联软删除:再删除该角色下的所有管理员关联
+ UPDATE public.ak_admin_roles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE role_id = p_id AND deleted_at IS NULL;
+
+ -- 5. 最后软删除角色本身
+ UPDATE public.ak_roles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_role(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_role(UUID) TO authenticated;
+-- RPC: rpc_admin_get_admin_list
+-- 管理端获取管理员列表
+-- 筛选 ak_users 表中 role 为 'admin' 或 'analytics' 的用户,并关联显示其角色信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_admin_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_users u
+ WHERE u.role IN ('admin', 'analytics')
+ AND (p_status IS NULL OR u.is_active = (p_status = 1))
+ AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据列表 (关联角色)
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ u.id,
+ u.username,
+ u.real_name,
+ u.role,
+ u.is_active,
+ u.last_login_at,
+ u.last_login_ip,
+ (
+ SELECT jsonb_agg(r.name)
+ FROM public.ak_admin_roles ar
+ JOIN public.ak_roles r ON r.id = ar.role_id
+ WHERE ar.user_id = u.id
+ ) as roles
+ FROM public.ak_users u
+ WHERE u.role IN ('admin', 'analytics')
+ AND (p_status IS NULL OR u.is_active = (p_status = 1))
+ AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%')
+ ORDER BY u.created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_get_permission_list
+-- 管理端获取全量权限/菜单列表 (供前端构建树形结构)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_permission_list()
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取全量数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, parent_id, name, code, type,
+ path, icon, sort_order, is_visible,
+ created_at, updated_at
+ FROM public.ak_permissions
+ WHERE deleted_at IS NULL
+ ORDER BY sort_order ASC, created_at ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_permission_list() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_permission_list() TO authenticated;
+-- RPC: rpc_admin_get_role_list
+-- 管理端获取角色分页列表
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_role_list(
+ p_search TEXT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_roles
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, code, description, is_active,
+ created_at, updated_at
+ FROM public.ak_roles
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_save_permission
+-- 管理端新增或更新功能权限/菜单
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_permission(
+ p_id UUID DEFAULT NULL,
+ p_parent_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_code TEXT DEFAULT NULL,
+ p_type TEXT DEFAULT 'menu',
+ p_path TEXT DEFAULT NULL,
+ p_icon TEXT DEFAULT NULL,
+ p_sort_order INTEGER DEFAULT 0,
+ p_is_visible BOOLEAN DEFAULT TRUE
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL OR p_code IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name or code';
+ END IF;
+
+ INSERT INTO public.ak_permissions (
+ parent_id, name, code, type, path, icon, sort_order, is_visible
+ ) VALUES (
+ p_parent_id, p_name, p_code, p_type, p_path, p_icon, p_sort_order, p_is_visible
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ak_permissions
+ SET
+ parent_id = COALESCE(p_parent_id, parent_id),
+ name = COALESCE(p_name, name),
+ code = COALESCE(p_code, code),
+ type = COALESCE(p_type, type),
+ path = COALESCE(p_path, path),
+ icon = COALESCE(p_icon, icon),
+ sort_order = COALESCE(p_sort_order, sort_order),
+ is_visible = COALESCE(p_is_visible, is_visible),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Permission item not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) TO authenticated;
+-- RPC: rpc_admin_save_role
+-- 管理端新增或更新角色
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_role(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_code TEXT DEFAULT NULL,
+ p_description TEXT DEFAULT NULL,
+ p_is_active BOOLEAN DEFAULT TRUE
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL OR p_code IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name or code';
+ END IF;
+
+ INSERT INTO public.ak_roles (
+ name, code, description, is_active
+ ) VALUES (
+ p_name, p_code, p_description, p_is_active
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ak_roles
+ SET
+ name = COALESCE(p_name, name),
+ code = COALESCE(p_code, code),
+ description = COALESCE(p_description, description),
+ is_active = COALESCE(p_is_active, is_active),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Role not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_delete
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除文章分类(支持级联软删除分类下的文章)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('cms:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: cms:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该分类下的所有文章
+ UPDATE public.ml_articles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 软删除分类本身
+ UPDATE public.ml_article_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_delete IS '管理员删除文章分类(级联软删除关联文章)';
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_list
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取文章分类列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_article_categories
+ WHERE (p_search IS NULL OR name ILIKE '%' || p_search || '%');
+
+ -- 3. 获取列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT id, name, icon, sort, status, created_at, updated_at
+ FROM public.ml_article_categories
+ WHERE (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ ORDER BY sort ASC, created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_save
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新文章分类
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_icon TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_article_categories (
+ name, icon, sort, status
+ ) VALUES (
+ p_name, p_icon, p_sort, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 4. 更新
+ UPDATE public.ml_article_categories
+ SET
+ name = p_name,
+ icon = COALESCE(p_icon, icon),
+ sort = p_sort,
+ status = p_status,
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Category not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_save IS '管理员新增或更新文章分类';
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_set_status
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换文章分类启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_article_categories
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_set_status IS '管理员设置文章分类状态';
+-- =====================================================================================
+-- RPC: rpc_admin_article_delete
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除文章记录(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('cms:article:delete') THEN
+ RAISE EXCEPTION 'Permission denied: cms:article:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除
+ UPDATE public.ml_articles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_delete IS '管理员删除文章记录';
+-- =====================================================================================
+-- RPC: rpc_admin_article_get_detail
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取指定文章的完整详情
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_get_detail(
+ p_id UUID
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_item JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取详情
+ SELECT jsonb_build_object(
+ 'id', a.id,
+ 'category_id', a.category_id,
+ 'category_name', c.name,
+ 'title', a.title,
+ 'author', a.author,
+ 'image', a.image,
+ 'description', a.description,
+ 'content', a.content,
+ 'status', a.status,
+ 'views', a.views,
+ 'is_banner', a.is_banner,
+ 'is_hot', a.is_hot,
+ 'linked_product_id', a.linked_product_id,
+ 'created_at', a.created_at,
+ 'updated_at', a.updated_at
+ ) INTO v_item
+ FROM public.ml_articles a
+ LEFT JOIN public.ml_article_categories c ON c.id = a.category_id
+ WHERE a.id = p_id;
+
+ IF v_item IS NULL THEN
+ RAISE EXCEPTION 'Article not found';
+ END IF;
+
+ RETURN v_item;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_get_detail IS '管理员获取文章完整详情';
+-- =====================================================================================
+-- RPC: rpc_admin_article_list
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取文章列表,支持搜索、分类筛选及状态过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_category_id UUID DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_articles a
+ WHERE (p_category_id IS NULL OR a.category_id = p_category_id)
+ AND (p_status IS NULL OR a.status = p_status)
+ AND (p_search IS NULL OR a.title ILIKE '%' || p_search || '%' OR a.author ILIKE '%' || p_search || '%');
+
+ -- 3. 获取列表数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ a.id,
+ a.category_id,
+ c.name as category_name,
+ a.title,
+ a.author,
+ a.image,
+ a.description,
+ a.status,
+ a.views,
+ a.is_banner,
+ a.is_hot,
+ a.created_at,
+ a.updated_at
+ FROM public.ml_articles a
+ LEFT JOIN public.ml_article_categories c ON c.id = a.category_id
+ WHERE (p_category_id IS NULL OR a.category_id = p_category_id)
+ AND (p_status IS NULL OR a.status = p_status)
+ AND (p_search IS NULL OR a.title ILIKE '%' || p_search || '%' OR a.author ILIKE '%' || p_search || '%')
+ ORDER BY a.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_list IS '管理员分页查询文章列表';
+-- =====================================================================================
+-- RPC: rpc_admin_article_save
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新文章内容
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_save(
+ p_id UUID DEFAULT NULL,
+ p_category_id UUID DEFAULT NULL,
+ p_title TEXT DEFAULT NULL,
+ p_author TEXT DEFAULT NULL,
+ p_image TEXT DEFAULT NULL,
+ p_description TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 0,
+ p_is_banner BOOLEAN DEFAULT FALSE,
+ p_is_hot BOOLEAN DEFAULT FALSE,
+ p_linked_product_id UUID DEFAULT NULL
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_title IS NULL OR length(trim(p_title)) = 0 THEN
+ RAISE EXCEPTION 'Invalid title';
+ END IF;
+ IF p_category_id IS NULL THEN
+ RAISE EXCEPTION 'Category is required';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_articles (
+ category_id, title, author, image, description, content,
+ status, is_banner, is_hot, linked_product_id
+ ) VALUES (
+ p_category_id, p_title, p_author, p_image, p_description, p_content,
+ p_status, p_is_banner, p_is_hot, p_linked_product_id
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 4. 更新
+ UPDATE public.ml_articles
+ SET
+ category_id = COALESCE(p_category_id, category_id),
+ title = COALESCE(p_title, title),
+ author = COALESCE(p_author, author),
+ image = COALESCE(p_image, image),
+ description = COALESCE(p_description, description),
+ content = COALESCE(p_content, content),
+ status = COALESCE(p_status, status),
+ is_banner = COALESCE(p_is_banner, is_banner),
+ is_hot = COALESCE(p_is_hot, is_hot),
+ linked_product_id = p_linked_product_id,
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Article not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_save IS '管理员新增或更新文章内容';
+-- =====================================================================================
+-- RPC: rpc_admin_article_set_status
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换文章发布/下架状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_articles
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_set_status IS '管理员设置文章发布状态';
+-- RPC: rpc_admin_delete_diy_page
+-- 管理端删除 DIY 页面配置(支持权限检查与首页保护)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_diy_page(
+ p_id uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数)
+ IF NOT public.check_admin_permission('decoration:page:delete') THEN
+ RAISE EXCEPTION 'Permission denied: decoration:page:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 校验:不允许删除当前生效的首页
+ IF EXISTS (
+ SELECT 1 FROM public.ak_diy_pages
+ WHERE id = p_id AND is_home = true AND deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'cannot delete the active home page';
+ END IF;
+
+ -- 4. 执行软删除:标记 deleted_at
+ UPDATE public.ak_diy_pages
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_diy_page(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_diy_page(uuid) TO authenticated;
+-- RPC: rpc_admin_get_diy_page_list
+-- 管理端获取 DIY 页面分页列表
+-- 支持按名称搜索和按类型筛选
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_diy_page_list(
+ p_search text DEFAULT NULL,
+ p_type text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+ v_total bigint;
+ v_items jsonb;
+BEGIN
+ -- 1. 权限检查 (仅管理员或分析员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_diy_pages
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%')
+ AND (p_type IS NULL OR type = p_type);
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, type, is_home, is_active,
+ created_at, updated_at
+ FROM public.ak_diy_pages
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%')
+ AND (p_type IS NULL OR type = p_type)
+ ORDER BY created_at DESC
+ LIMIT v_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回 JSON 结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_save_diy_page
+-- 管理端新增或更新 DIY 页面配置
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_diy_page(
+ p_id uuid DEFAULT NULL,
+ p_name text DEFAULT NULL,
+ p_type text DEFAULT NULL,
+ p_config jsonb DEFAULT '{}'::jsonb,
+ p_is_active boolean DEFAULT true
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id uuid;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_diy_pages (
+ name, type, config, is_active, updated_by, created_by
+ ) VALUES (
+ p_name, p_type, p_config, p_is_active, auth.uid(), auth.uid()
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ak_diy_pages
+ SET
+ name = COALESCE(p_name, name),
+ type = COALESCE(p_type, type),
+ config = COALESCE(p_config, config),
+ is_active = COALESCE(p_is_active, is_active),
+ updated_at = now(),
+ updated_by = auth.uid()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'page not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) TO authenticated;
+-- RPC: rpc_admin_set_home_page
+-- 管理端设置生效首页
+-- 逻辑:先取消所有同类型页面的 is_home 状态,再设置目标页面为 is_home
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_set_home_page(
+ p_id uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_type text;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 获取目标页面类型
+ SELECT type INTO v_type FROM public.ak_diy_pages WHERE id = p_id;
+ IF v_type IS NULL THEN
+ RAISE EXCEPTION 'page not found';
+ END IF;
+
+ -- 3. 原子切换:同一类型的页面只能有一个 is_home
+ UPDATE public.ak_diy_pages SET is_home = false WHERE type = v_type;
+ UPDATE public.ak_diy_pages SET is_home = true WHERE id = p_id;
+
+ RETURN true;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_set_home_page(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_set_home_page(uuid) TO authenticated;
+-- RPC: rpc_admin_delete_delivery_staff
+-- 管理端删除配送员(支持权限检查)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_staff(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数)
+ IF NOT public.check_admin_permission('delivery:staff:delete') THEN
+ RAISE EXCEPTION 'Permission denied: delivery:staff:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_delivery_staff
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) TO authenticated;
+-- RPC: rpc_admin_delete_delivery_station
+-- 管理端删除提货点/核销点(支持级联软删除配送员关联)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_station(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('delivery:station:delete') THEN
+ RAISE EXCEPTION 'Permission denied: delivery:station:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该站点下的所有配送员
+ UPDATE public.ml_delivery_staff
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE station_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 执行软删除站点本身
+ UPDATE public.ml_delivery_stations
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) TO authenticated;
+-- RPC: rpc_admin_get_delivery_staff_list
+-- 管理端获取配送员分页列表
+-- 支持按姓名或手机号搜索
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_staff_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_staff
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, uid, nickname, avatar, phone, status, is_active,
+ created_at, updated_at
+ FROM public.ml_delivery_staff
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_get_delivery_staff_list
+-- 管理端获取服务人员分页列表(v2)
+
+DROP FUNCTION IF EXISTS public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER);
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_staff_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND (p_status IS NULL OR s.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR s.nickname ILIKE '%' || p_search || '%'
+ OR s.phone ILIKE '%' || p_search || '%'
+ OR s.staff_no ILIKE '%' || p_search || '%'
+ );
+
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ s.id,
+ s.uid,
+ s.station_id,
+ st.name AS station_name,
+ s.staff_no,
+ s.nickname,
+ s.avatar,
+ s.phone,
+ s.status,
+ s.is_active,
+ s.online_status,
+ s.certificate_status,
+ s.certificate_expire_at,
+ s.service_area,
+ s.skills,
+ s.created_at,
+ s.updated_at
+ FROM public.ml_delivery_staff s
+ LEFT JOIN public.ml_delivery_stations st ON st.id = s.station_id AND st.deleted_at IS NULL
+ WHERE s.deleted_at IS NULL
+ AND (p_status IS NULL OR s.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR s.nickname ILIKE '%' || p_search || '%'
+ OR s.phone ILIKE '%' || p_search || '%'
+ OR s.staff_no ILIKE '%' || p_search || '%'
+ )
+ ORDER BY s.created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;-- RPC: rpc_admin_get_delivery_station_list
+-- 管理端获取提货点/核销点分页列表
+-- 支持按名称、地址或手机号搜索
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_station_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_stations
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = ''
+ OR name ILIKE '%' || p_search || '%'
+ OR address ILIKE '%' || p_search || '%'
+ OR phone ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, phone, address, image,
+ lng, lat, status, sort_order, business_hours,
+ created_at, updated_at
+ FROM public.ml_delivery_stations
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = ''
+ OR name ILIKE '%' || p_search || '%'
+ OR address ILIKE '%' || p_search || '%'
+ OR phone ILIKE '%' || p_search || '%')
+ ORDER BY sort_order ASC, created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_save_delivery_staff
+-- 管理端新增或更新配送员信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_staff(
+ p_id UUID DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_nickname IS NULL OR p_phone IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: nickname or phone';
+ END IF;
+
+ -- 3. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_staff (
+ nickname, avatar, phone, status
+ ) VALUES (
+ p_nickname, p_avatar, p_phone, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_staff
+ SET
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ phone = COALESCE(p_phone, phone),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Delivery staff not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) TO authenticated;
+-- RPC: rpc_admin_save_delivery_staff
+-- 管理端新增或更新服务人员信息(v2)
+
+DROP FUNCTION IF EXISTS public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT);
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_staff(
+ p_id UUID DEFAULT NULL,
+ p_uid UUID DEFAULT NULL,
+ p_station_id UUID DEFAULT NULL,
+ p_staff_no TEXT DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1,
+ p_online_status TEXT DEFAULT 'resting',
+ p_certificate_status TEXT DEFAULT 'pending',
+ p_certificate_expire_at DATE DEFAULT NULL,
+ p_service_area TEXT DEFAULT '',
+ p_skills JSONB DEFAULT '[]'::jsonb
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ IF p_nickname IS NULL OR p_phone IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: nickname or phone';
+ END IF;
+
+ IF p_online_status NOT IN ('online', 'resting', 'busy') THEN
+ RAISE EXCEPTION 'Invalid online_status';
+ END IF;
+
+ IF p_certificate_status NOT IN ('valid', 'expired', 'pending') THEN
+ RAISE EXCEPTION 'Invalid certificate_status';
+ END IF;
+
+ IF p_station_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_stations s
+ WHERE s.id = p_station_id AND s.deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'Delivery station not found';
+ END IF;
+
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_staff (
+ uid, station_id, staff_no, nickname, avatar, phone, status,
+ online_status, certificate_status, certificate_expire_at,
+ service_area, skills
+ ) VALUES (
+ p_uid, p_station_id, NULLIF(p_staff_no, ''), p_nickname, p_avatar, p_phone, p_status,
+ p_online_status, p_certificate_status, p_certificate_expire_at,
+ COALESCE(p_service_area, ''), COALESCE(p_skills, '[]'::jsonb)
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_staff
+ SET
+ uid = COALESCE(p_uid, uid),
+ station_id = p_station_id,
+ staff_no = CASE WHEN p_staff_no IS NULL OR p_staff_no = '' THEN NULL ELSE p_staff_no END,
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ phone = COALESCE(p_phone, phone),
+ status = COALESCE(p_status, status),
+ online_status = COALESCE(p_online_status, online_status),
+ certificate_status = COALESCE(p_certificate_status, certificate_status),
+ certificate_expire_at = p_certificate_expire_at,
+ service_area = COALESCE(p_service_area, service_area),
+ skills = COALESCE(p_skills, skills),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Delivery staff not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, UUID, UUID, TEXT, TEXT, TEXT, TEXT, SMALLINT, TEXT, TEXT, DATE, TEXT, JSONB) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, UUID, UUID, TEXT, TEXT, TEXT, TEXT, SMALLINT, TEXT, TEXT, DATE, TEXT, JSONB) TO authenticated;-- RPC: rpc_admin_save_delivery_station
+-- 管理端新增或更新提货点/核销点信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_station(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_address TEXT DEFAULT NULL,
+ p_image TEXT DEFAULT NULL,
+ p_lng NUMERIC DEFAULT NULL,
+ p_lat NUMERIC DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1,
+ p_sort_order INTEGER DEFAULT 0,
+ p_business_hours JSONB DEFAULT NULL
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR p_phone IS NULL OR p_address IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name, phone or address';
+ END IF;
+
+ -- 3. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_stations (
+ name, phone, address, image, lng, lat, status, sort_order, business_hours
+ ) VALUES (
+ p_name, p_phone, p_address, p_image, p_lng, p_lat, p_status, p_sort_order, p_business_hours
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_stations
+ SET
+ name = COALESCE(p_name, name),
+ phone = COALESCE(p_phone, phone),
+ address = COALESCE(p_address, address),
+ image = COALESCE(p_image, image),
+ lng = COALESCE(p_lng, lng),
+ lat = COALESCE(p_lat, lat),
+ status = COALESCE(p_status, status),
+ sort_order = COALESCE(p_sort_order, sort_order),
+ business_hours = COALESCE(p_business_hours, business_hours),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Station not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_delivery_* homecare actions
+-- Version: v1
+-- Purpose: 为 delivery 页面当前 api/delivery.uts 已固定的 rpc_delivery_* 契约补齐 SQL 端实现。
+-- Notes:
+-- 1. 优先兼容 ec/hc 新链,检测不到表或执行失败时回退 hss 旧链。
+-- 2. 返回 JSON 结构直接对齐 delivery/types/delivery.uts。
+-- 3. message_list 与 record_list 采用最小可用实现,避免前端继续落入 fallback。
+-- =====================================================================================
+
+-- 迁移正文见同批文件:mall_sql/migrations/20260526_delivery_homecare_rpc_v1.sql-- =====================================================================================
+-- RPC: rpc_homecare_dispatch_candidate
+-- Version: v1
+-- Purpose: 为居家上门服务返回单个可派单候选人,替代公开可派单人员 RLS。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends: public.ak_users, public.ml_delivery_staff
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_homecare_dispatch_candidate(
+ p_service_code TEXT DEFAULT NULL,
+ p_station_id UUID DEFAULT NULL
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_candidate JSONB;
+BEGIN
+ IF auth.uid() IS NULL OR NOT EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.auth_id = auth.uid()
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ SELECT jsonb_build_object(
+ 'id', s.id,
+ 'uid', s.uid,
+ 'station_id', s.station_id,
+ 'status', s.status,
+ 'online_status', s.online_status,
+ 'updated_at', s.updated_at,
+ 'created_at', s.created_at
+ )
+ INTO v_candidate
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ AND (p_station_id IS NULL OR s.station_id = p_station_id)
+ ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.created_at DESC
+ LIMIT 1;
+
+ RETURN v_candidate;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_homecare_dispatch_candidate(TEXT, UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_homecare_dispatch_candidate(TEXT, UUID) TO authenticated;-- RPC: rpc_admin_delete_agent
+-- 管理端删除代理商(支持级联软删除代理申请记录)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_agent(
+ p_uid uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('distribution:agent:delete') THEN
+ RAISE EXCEPTION 'Permission denied: distribution:agent:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该代理的所有申请记录
+ UPDATE public.ak_distribution_agent_applications
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE user_id = p_uid AND deleted_at IS NULL;
+
+ -- 4. 软删除代理商记录
+ UPDATE public.ak_distribution_agents
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE uid = p_uid AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_agent(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_agent(uuid) TO authenticated;
+-- RPC: rpc_admin_delete_division
+-- 管理端删除事业部(支持级联软删除关联代理)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_division(
+ p_uid uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('distribution:division:delete') THEN
+ RAISE EXCEPTION 'Permission denied: distribution:division:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该事业部下的所有代理商
+ UPDATE public.ak_distribution_agents
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE division_uid = p_uid AND deleted_at IS NULL;
+
+ -- 4. 级联软删除:该事业部的所有申请记录
+ UPDATE public.ak_distribution_division_applications
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE user_id = p_uid AND deleted_at IS NULL;
+
+ -- 5. 软删除事业部本身
+ UPDATE public.ak_distribution_divisions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE uid = p_uid AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_division(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_division(uuid) TO authenticated;
+-- RPC: rpc_admin_get_agent_apply_list
+-- 管理端获取代理商申请列表
+-- 支持按状态过滤:all, pending, approved, rejected
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_apply_list(
+ p_status text DEFAULT 'all',
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ id uuid,
+ uid uuid,
+ name text,
+ phone text,
+ dept_uid uuid,
+ dept_name text,
+ proof_images jsonb,
+ status text,
+ refusal_reason text,
+ time timestamptz,
+ invite_code text
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ a.id,
+ a.uid,
+ a.agent_name AS name,
+ a.agent_phone AS phone,
+ a.division_uid AS dept_uid,
+ d.name AS dept_name,
+ a.proof_images,
+ a.status,
+ a.refusal_reason,
+ a.created_at AS time,
+ d.invite_code
+ FROM public.ak_distribution_agent_applications a
+ JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid
+ WHERE (p_status = 'all' OR a.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR a.agent_name ILIKE ('%' || p_search || '%')
+ OR a.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY a.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_agent_list
+-- 管理端获取代理商列表
+-- 支持搜索代理商名称或负责人UID,并关联显示所属事业部信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ uid uuid,
+ name text,
+ division_uid uuid,
+ division_name text,
+ commission_ratio numeric,
+ is_enabled boolean,
+ end_time timestamptz,
+ created_at timestamptz,
+ "staffCount" bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ a.uid,
+ a.name,
+ a.division_uid,
+ d.name AS division_name,
+ a.commission_ratio,
+ a.is_enabled,
+ a.end_time,
+ a.created_at,
+ (SELECT COUNT(*) FROM public.ak_promoter_relations r WHERE r.inviter_uid = a.uid)::bigint AS "staffCount"
+ FROM public.ak_distribution_agents a
+ JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR a.name ILIKE ('%' || p_search || '%')
+ OR a.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY a.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_division_list
+-- 管理端获取事业部列表
+-- 支持搜索事业部名称或负责人UID
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_division_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ uid uuid,
+ name text,
+ invite_code text,
+ commission_ratio numeric,
+ is_enabled boolean,
+ end_time timestamptz,
+ created_at timestamptz,
+ "agentCount" bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 仅管理员或分析员可调用
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ d.uid,
+ d.name,
+ d.invite_code,
+ d.commission_ratio,
+ d.is_enabled,
+ d.end_time,
+ d.created_at,
+ (SELECT COUNT(*) FROM public.ak_distribution_agents a WHERE a.division_uid = d.uid)::bigint AS "agentCount"
+ FROM public.ak_distribution_divisions d
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR d.name ILIKE ('%' || p_search || '%')
+ OR d.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY d.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_promoter_list
+-- 管理端推广员列表聚合统计
+-- 口径:集合=B(上级+下级都算)=> 关系表中出现过的 uid/inviter_uid 都算推广员候选
+-- 统计:
+-- - 推广用户数量:以该用户作为 inviter_uid 的下级人数
+-- - 推广订单数量/金额:其下级用户在 ml_orders 中已完成(order_status=4)的订单数与 paid_amount 汇总
+-- - 佣金:从 ak_commission_logs 聚合
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_promoter_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20,
+ p_start_time timestamptz DEFAULT NULL,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS TABLE (
+ id uuid,
+ nickname text,
+ name text,
+ phone text,
+ avatar_url text,
+ level text,
+ "userCount" bigint,
+ "orderCount" bigint,
+ "orderAmount" numeric,
+ "commissionTotal" numeric,
+ "withdrawnAmount" numeric,
+ "withdrawCount" bigint,
+ "unwithdrawnAmount" numeric
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 仅管理员可调用
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ WITH promoters AS (
+ SELECT DISTINCT x.uid
+ FROM (
+ SELECT r.uid FROM public.ak_promoter_relations r
+ UNION
+ SELECT r.inviter_uid FROM public.ak_promoter_relations r
+ ) x
+ ),
+ base AS (
+ SELECT
+ u.id,
+ u.username AS nickname,
+ u.real_name AS name,
+ u.phone,
+ u.avatar_url,
+ u.role AS level
+ FROM promoters p
+ JOIN public.ak_users u ON u.id = p.uid
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR u.username ILIKE ('%' || p_search || '%')
+ OR COALESCE(u.real_name, '') ILIKE ('%' || p_search || '%')
+ OR COALESCE(u.phone, '') ILIKE ('%' || p_search || '%')
+ OR u.id::text ILIKE ('%' || p_search || '%')
+ )
+ ),
+ downline AS (
+ SELECT inviter_uid, uid
+ FROM public.ak_promoter_relations
+ ),
+ user_stats AS (
+ SELECT
+ d.inviter_uid AS id,
+ COUNT(*)::bigint AS "userCount"
+ FROM downline d
+ GROUP BY d.inviter_uid
+ ),
+ order_stats AS (
+ SELECT
+ d.inviter_uid AS id,
+ COUNT(o.id)::bigint AS "orderCount",
+ COALESCE(SUM(o.paid_amount), 0)::numeric AS "orderAmount"
+ FROM downline d
+ JOIN public.ml_orders o ON o.user_id = d.uid
+ WHERE o.order_status = 4
+ AND (p_start_time IS NULL OR o.completed_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.completed_at <= p_end_time)
+ GROUP BY d.inviter_uid
+ ),
+ commission_stats AS (
+ SELECT
+ c.uid AS id,
+ COALESCE(SUM(c.amount), 0)::numeric AS "commissionTotal",
+ COALESCE(SUM(CASE WHEN c.status = 'withdrawn' THEN c.amount ELSE 0 END), 0)::numeric AS "withdrawnAmount",
+ 0::bigint AS "withdrawCount",
+ COALESCE(SUM(CASE WHEN c.status IN ('frozen','available') THEN c.amount ELSE 0 END), 0)::numeric AS "unwithdrawnAmount"
+ FROM public.ak_commission_logs c
+ GROUP BY c.uid
+ )
+ SELECT
+ b.id,
+ b.nickname,
+ b.name,
+ b.phone,
+ b.avatar_url,
+ b.level,
+ COALESCE(us."userCount", 0) AS "userCount",
+ COALESCE(os."orderCount", 0) AS "orderCount",
+ COALESCE(os."orderAmount", 0) AS "orderAmount",
+ COALESCE(cs."commissionTotal", 0) AS "commissionTotal",
+ COALESCE(cs."withdrawnAmount", 0) AS "withdrawnAmount",
+ COALESCE(cs."withdrawCount", 0) AS "withdrawCount",
+ COALESCE(cs."unwithdrawnAmount", 0) AS "unwithdrawnAmount"
+ FROM base b
+ LEFT JOIN user_stats us ON us.id = b.id
+ LEFT JOIN order_stats os ON os.id = b.id
+ LEFT JOIN commission_stats cs ON cs.id = b.id
+ ORDER BY b.id
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+-- 授权:仅允许 authenticated 调用,函数内部再做 admin 校验
+REVOKE ALL ON FUNCTION public.rpc_admin_get_promoter_list(text, integer, integer, timestamptz, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_promoter_list(text, integer, integer, timestamptz, timestamptz) TO authenticated;
+-- RPC: rpc_admin_process_agent_apply
+-- 管理端审核代理商申请
+-- 若通过(approved),则同步在 ak_distribution_agents 中创建或更新记录
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_process_agent_apply(
+ p_id uuid,
+ p_status text, -- approved / rejected
+ p_refusal_reason text DEFAULT NULL
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_uid uuid;
+ v_division_uid uuid;
+ v_agent_name text;
+BEGIN
+ -- 仅管理员可审核
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 1. 获取并锁定申请记录
+ SELECT uid, division_uid, agent_name
+ INTO v_uid, v_division_uid, v_agent_name
+ FROM public.ak_distribution_agent_applications
+ WHERE id = p_id;
+
+ IF v_uid IS NULL THEN
+ RAISE EXCEPTION 'application record not found';
+ END IF;
+
+ -- 2. 更新申请状态
+ UPDATE public.ak_distribution_agent_applications
+ SET
+ status = p_status,
+ refusal_reason = CASE WHEN p_status = 'rejected' THEN p_refusal_reason ELSE NULL END,
+ approved_at = now(),
+ approved_by = auth.uid(),
+ updated_at = now()
+ WHERE id = p_id;
+
+ -- 3. 如果通过,则同步到代理商正式表
+ IF p_status = 'approved' THEN
+ INSERT INTO public.ak_distribution_agents (
+ uid, division_uid, name, commission_ratio, is_enabled, updated_at, updated_by
+ )
+ VALUES (
+ v_uid, v_division_uid, v_agent_name, 0, true, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ division_uid = EXCLUDED.division_uid,
+ name = EXCLUDED.name,
+ updated_at = now(),
+ updated_by = auth.uid();
+ END IF;
+
+ RETURN true;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) TO authenticated;
+-- RPC: rpc_admin_save_agent
+-- 管理端新增或更新代理商
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_agent(
+ p_uid uuid,
+ p_division_uid uuid,
+ p_name text,
+ p_commission_ratio numeric,
+ p_is_enabled boolean DEFAULT true,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ -- 仅管理员可操作
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 确保事业部存在
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_distribution_divisions WHERE uid = p_division_uid
+ ) THEN
+ RAISE EXCEPTION 'parent division not found';
+ END IF;
+
+ INSERT INTO public.ak_distribution_agents (
+ uid, division_uid, name, commission_ratio, is_enabled, end_time, updated_at, updated_by
+ )
+ VALUES (
+ p_uid, p_division_uid, p_name, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ division_uid = EXCLUDED.division_uid,
+ name = EXCLUDED.name,
+ commission_ratio = EXCLUDED.commission_ratio,
+ is_enabled = EXCLUDED.is_enabled,
+ end_time = EXCLUDED.end_time,
+ updated_at = now(),
+ updated_by = auth.uid();
+
+ RETURN p_uid;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) TO authenticated;
+-- RPC: rpc_admin_save_division
+-- 管理端新增或更新事业部
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_division(
+ p_uid uuid,
+ p_name text,
+ p_invite_code text,
+ p_commission_ratio numeric,
+ p_is_enabled boolean DEFAULT true,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ -- 仅管理员可操作
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ INSERT INTO public.ak_distribution_divisions (
+ uid, name, invite_code, commission_ratio, is_enabled, end_time, updated_at, updated_by
+ )
+ VALUES (
+ p_uid, p_name, p_invite_code, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ name = EXCLUDED.name,
+ invite_code = EXCLUDED.invite_code,
+ commission_ratio = EXCLUDED.commission_ratio,
+ is_enabled = EXCLUDED.is_enabled,
+ end_time = EXCLUDED.end_time,
+ updated_at = now(),
+ updated_by = auth.uid();
+
+ RETURN p_uid;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) TO authenticated;
+-- =====================================================================================
+-- Admin 财务统计 - 余额收支分布统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按业务子类型统计指定时间范围内的余额收入与支出分布
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_distribution(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_income DECIMAL(12,2);
+ v_total_expense DECIMAL(12,2);
+ v_income_items JSONB;
+ v_expense_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算总收入与总支出
+ SELECT
+ COALESCE(SUM(number) FILTER (WHERE pm = 1), 0),
+ COALESCE(SUM(number) FILTER (WHERE pm = 0), 0)
+ INTO v_total_income, v_total_expense
+ FROM public.ml_user_bill
+ WHERE category = 'balance'
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND status = 1;
+
+ -- 3. 统计收入分布 (来源分析)
+ SELECT jsonb_agg(t) INTO v_income_items
+ FROM (
+ SELECT
+ type AS name,
+ SUM(number) AS value,
+ CASE WHEN v_total_income > 0 THEN ROUND(SUM(number) / v_total_income * 100, 2) ELSE 0 END AS percent
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND pm = 1 AND status = 1
+ AND created_at >= p_start_time AND created_at <= p_end_time
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ -- 4. 统计支出分布 (消耗分析)
+ SELECT jsonb_agg(t) INTO v_expense_items
+ FROM (
+ SELECT
+ type AS name,
+ SUM(number) AS value,
+ CASE WHEN v_total_expense > 0 THEN ROUND(SUM(number) / v_total_expense * 100, 2) ELSE 0 END AS percent
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND pm = 0 AND status = 1
+ AND created_at >= p_start_time AND created_at <= p_end_time
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'income', COALESCE(v_income_items, '[]'::jsonb),
+ 'expense', COALESCE(v_expense_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_distribution IS '统计财务余额收支来源与消耗分布';
+-- =====================================================================================
+-- Admin 财务统计 - 余额核心指标 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取全站当前余额存量、累计增加总额及累计消耗总额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_current_balance DECIMAL(12,2);
+ v_total_accumulation DECIMAL(12,2);
+ v_total_consumption DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计当前全站用户余额总存量
+ SELECT COALESCE(SUM(now_money), 0) INTO v_current_balance FROM public.ak_users;
+
+ -- 3. 统计累计增加 (pm=1) 和 累计消耗 (pm=0)
+ -- 基于 ml_user_bill 表中 category='balance' 的记录
+ SELECT
+ COALESCE(SUM(number) FILTER (WHERE pm = 1), 0),
+ COALESCE(SUM(number) FILTER (WHERE pm = 0), 0)
+ INTO v_total_accumulation, v_total_consumption
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND status = 1;
+
+ RETURN jsonb_build_object(
+ 'current_balance', v_current_balance,
+ 'total_accumulation', v_total_accumulation,
+ 'total_consumption', v_total_consumption
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_stats IS '获取全站余额存量及累计收支汇总';
+-- =====================================================================================
+-- Admin 财务统计 - 余额收支趋势 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按日聚合指定时间范围内的余额积累 (pm=1) 与 余额消耗 (pm=0) 趋势
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_trend(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按日聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'YYYY-MM-DD') AS date_group,
+ COALESCE(SUM(number) FILTER (WHERE pm = 1 AND category = 'balance'), 0) AS accumulation,
+ COALESCE(SUM(number) FILTER (WHERE pm = 0 AND category = 'balance'), 0) AS consumption
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ LEFT JOIN public.ml_user_bill b ON date_trunc('day', b.created_at) = gs.day AND b.status = 1
+ GROUP BY gs.day
+ ORDER BY gs.day ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_trend IS '按日聚合财务余额收支趋势';
+-- =====================================================================================
+-- Admin 财务功能 - 提现申请列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_extract, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_extract_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_status SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_extract e
+ LEFT JOIN public.ak_users u ON u.id = e.uid
+ WHERE (p_status IS NULL OR e.status = p_status)
+ AND (p_start_time IS NULL OR e.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR e.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.real_name, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.bank_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.alipay_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.wechat_code, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ e.id,
+ e.uid,
+ e.real_name,
+ e.extract_type,
+ e.bank_code,
+ e.bank_address,
+ e.alipay_code,
+ e.wechat_code,
+ e.extract_price,
+ e.service_fee,
+ e.balance,
+ e.status,
+ e.refusal_reason,
+ e.admin_id,
+ e.payment_time,
+ e.created_at,
+ e.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_extract e
+ LEFT JOIN public.ak_users u ON u.id = e.uid
+ WHERE (p_status IS NULL OR e.status = p_status)
+ AND (p_start_time IS NULL OR e.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR e.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.real_name, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.bank_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.alipay_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.wechat_code, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY e.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_extract_list IS '管理员提现申请列表分页查询';
+-- =====================================================================================
+-- Admin 财务功能 - 提现审核 RPC (口径 2)
+-- 位置:docs/sql/30_rpc/finance/
+-- 版本:v1
+-- 描述:提现审核通过时才扣除佣金并生成流水。
+-- 安全策略:SECURITY DEFINER, 入口鉴权, 固定 search_path
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_extract_review(
+ p_extract_id UUID,
+ p_status SMALLINT, -- 1: 通过, -1: 驳回
+ p_refusal_reason TEXT DEFAULT NULL
+)
+RETURNS VOID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_extract RECORD;
+ v_user RECORD;
+BEGIN
+ -- 1. 鉴权:仅 admin 角色可执行
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 锁定并获取提现记录
+ SELECT * INTO v_extract FROM public.ml_extract WHERE id = p_extract_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'Extract record not found'; END IF;
+ IF v_extract.status != 0 THEN RAISE EXCEPTION 'Record already processed'; END IF;
+
+ -- 3. 业务处理
+ IF p_status = 1 THEN
+ -- 审核通过:锁定并校验用户资金
+ SELECT * INTO v_user FROM public.ak_users WHERE id = v_extract.uid FOR UPDATE;
+ IF v_user.brokerage_price < v_extract.extract_price THEN
+ RAISE EXCEPTION 'Insufficient brokerage balance';
+ END IF;
+
+ -- 扣除佣金
+ UPDATE public.ak_users
+ SET brokerage_price = brokerage_price - v_extract.extract_price
+ WHERE id = v_extract.uid;
+
+ -- 写入资金流水
+ INSERT INTO public.ml_user_bill (uid, link_id, pm, title, category, type, number, balance)
+ VALUES (
+ v_extract.uid,
+ p_extract_id::TEXT,
+ 0, -- 支出
+ '佣金提现',
+ 'brokerage',
+ 'extract',
+ v_extract.extract_price,
+ v_user.brokerage_price - v_extract.extract_price
+ );
+
+ -- 更新提现记录
+ UPDATE public.ml_extract
+ SET status = 1, admin_id = auth.uid(), payment_time = now()
+ WHERE id = p_extract_id;
+
+ ELSIF p_status = -1 THEN
+ -- 审核驳回:仅更新状态
+ UPDATE public.ml_extract
+ SET status = -1, refusal_reason = p_refusal_reason, admin_id = auth.uid()
+ WHERE id = p_extract_id;
+ ELSE
+ RAISE EXCEPTION 'Invalid status';
+ END IF;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_extract_review IS '管理员审核提现申请(口径 2:通过时扣款)';
+-- =====================================================================================
+-- Admin 财务功能 - 账单汇总统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按日/周/月维度聚合财务收支数据,支撑账单列表展示
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_finance_bill_summary(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE,
+ p_interval TEXT DEFAULT 'day' -- day, week, month
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc(p_interval, created_at),
+ CASE
+ WHEN p_interval = 'day' THEN 'YYYY-MM-DD'
+ WHEN p_interval = 'week' THEN 'IYYY-IW'
+ ELSE 'YYYY-MM'
+ END
+ ) AS date_group,
+ SUM(number) FILTER (WHERE pm = 1) AS income,
+ SUM(number) FILTER (WHERE pm = 0) AS expense,
+ SUM(CASE WHEN pm = 1 THEN number ELSE -number END) AS net_entry
+ FROM public.ml_user_bill
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND status = 1
+ GROUP BY date_trunc(p_interval, created_at)
+ ORDER BY date_trunc(p_interval, created_at) DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_finance_bill_summary IS '按周期聚合财务收支账单';
+-- =====================================================================================
+-- Admin 财务功能 - 财务概况统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取指定时间段内的财务核心 KPI(营业额、充值汇总、提现汇总、资金存量)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_finance_overview(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_recharge_amount DECIMAL(12,2);
+ v_recharge_count BIGINT;
+ v_extract_amount DECIMAL(12,2);
+ v_extract_count BIGINT;
+ v_total_user_balance DECIMAL(12,2);
+ v_total_user_brokerage DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计充值 (仅统计已支付)
+ SELECT
+ COALESCE(SUM(price + give_price), 0),
+ COUNT(*)
+ INTO v_recharge_amount, v_recharge_count
+ FROM public.ml_user_recharge
+ WHERE paid = 1
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time;
+
+ -- 3. 统计提现 (仅统计已通过)
+ SELECT
+ COALESCE(SUM(extract_price), 0),
+ COUNT(*)
+ INTO v_extract_amount, v_extract_count
+ FROM public.ml_extract
+ WHERE status = 1
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time;
+
+ -- 4. 统计全站资金存量 (实时快照)
+ SELECT
+ COALESCE(SUM(now_money), 0),
+ COALESCE(SUM(brokerage_price), 0)
+ INTO v_total_user_balance, v_total_user_brokerage
+ FROM public.ak_users;
+
+ RETURN jsonb_build_object(
+ 'recharge_amount', v_recharge_amount,
+ 'recharge_count', v_recharge_count,
+ 'extract_amount', v_extract_amount,
+ 'extract_count', v_extract_count,
+ 'total_user_balance', v_total_user_balance,
+ 'total_user_brokerage', v_total_user_brokerage
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_finance_overview IS '财务核心 KPI 概况统计';
+-- =====================================================================================
+-- RPC: rpc_admin_invoice_list
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取发票申请列表,支持搜索、状态筛选及时间过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_invoice_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_status SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_invoices i
+ LEFT JOIN public.ak_users u ON u.id = i.uid
+ WHERE (p_status IS NULL OR i.status = p_status)
+ AND (p_start_time IS NULL OR i.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR i.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ i.order_no ILIKE '%' || p_search || '%' OR
+ i.header_name ILIKE '%' || p_search || '%' OR
+ u.username ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ i.id,
+ i.uid,
+ i.order_no,
+ i.order_amount,
+ i.invoice_type,
+ i.header_type,
+ i.header_name,
+ i.tax_id,
+ i.email,
+ i.remark,
+ i.status,
+ i.refusal_reason,
+ i.invoice_url,
+ i.created_at,
+ i.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_invoices i
+ LEFT JOIN public.ak_users u ON u.id = i.uid
+ WHERE (p_status IS NULL OR i.status = p_status)
+ AND (p_start_time IS NULL OR i.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR i.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ i.order_no ILIKE '%' || p_search || '%' OR
+ i.header_name ILIKE '%' || p_search || '%' OR
+ u.username ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY i.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_invoice_list IS '管理员分页查询发票申请列表';
+-- =====================================================================================
+-- RPC: rpc_admin_invoice_process
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端处理发票申请(开票或驳回)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_invoice_process(
+ p_id UUID,
+ p_status SMALLINT, -- 1: 已开票, -1: 已拒绝
+ p_invoice_url TEXT DEFAULT NULL,
+ p_refusal_reason TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_invoices
+ SET
+ status = p_status,
+ invoice_url = CASE WHEN p_status = 1 THEN p_invoice_url ELSE invoice_url END,
+ refusal_reason = CASE WHEN p_status = -1 THEN p_refusal_reason ELSE refusal_reason END,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_invoice_process IS '管理员处理发票开票申请';
+-- =====================================================================================
+-- Admin 财务功能 - 充值补单/审计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 版本:v1
+-- 描述:由管理员发起的人工充值补单或离线支付审计确认。
+-- 安全策略:SECURITY DEFINER, 入口鉴权, 固定 search_path
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_recharge_audit(
+ p_recharge_id UUID,
+ p_mark TEXT DEFAULT '管理员人工审计/补单'
+)
+RETURNS VOID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_recharge RECORD;
+ v_user RECORD;
+BEGIN
+ -- 1. 鉴权:仅 admin 角色可执行
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 锁定并获取充值记录
+ SELECT * INTO v_recharge FROM public.ml_user_recharge WHERE id = p_recharge_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'Recharge record not found'; END IF;
+ IF v_recharge.paid = 1 THEN RAISE EXCEPTION 'Recharge already paid'; END IF;
+
+ -- 3. 锁定并更新用户余额
+ SELECT * INTO v_user FROM public.ak_users WHERE id = v_recharge.uid FOR UPDATE;
+
+ UPDATE public.ak_users
+ SET now_money = now_money + v_recharge.price + v_recharge.give_price
+ WHERE id = v_recharge.uid;
+
+ -- 4. 写入资金流水
+ INSERT INTO public.ml_user_bill (uid, link_id, pm, title, category, type, number, balance, mark)
+ VALUES (
+ v_recharge.uid,
+ v_recharge.order_no,
+ 1, -- 收入
+ '用户充值',
+ 'now_money',
+ 'recharge',
+ v_recharge.price + v_recharge.give_price,
+ v_user.now_money + v_recharge.price + v_recharge.give_price,
+ p_mark
+ );
+
+ -- 5. 更新充值记录状态
+ UPDATE public.ml_user_recharge
+ SET paid = 1, pay_time = now()
+ WHERE id = p_recharge_id;
+
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_recharge_audit IS '管理员人工审计/补单(更新用户余额并生成流水)';
+-- =====================================================================================
+-- Admin 财务功能 - 充值记录列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_user_recharge, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_recharge_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_paid SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_user_recharge r
+ LEFT JOIN public.ak_users u ON u.id = r.uid
+ WHERE (p_paid IS NULL OR r.paid = p_paid)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(r.order_no, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ r.id,
+ r.uid,
+ r.order_no,
+ r.recharge_type,
+ r.price,
+ r.give_price,
+ r.paid,
+ r.pay_time,
+ r.channel_trade_no,
+ r.status,
+ r.created_at,
+ r.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_user_recharge r
+ LEFT JOIN public.ak_users u ON u.id = r.uid
+ WHERE (p_paid IS NULL OR r.paid = p_paid)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(r.order_no, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY r.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_recharge_list IS '管理员充值记录列表分页查询';
+-- =====================================================================================
+-- Admin 财务功能 - 资金流水列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_user_bill, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_bill_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_category VARCHAR DEFAULT NULL,
+ p_type VARCHAR DEFAULT NULL,
+ p_pm SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_user_bill b
+ LEFT JOIN public.ak_users u ON u.id = b.uid
+ WHERE (p_category IS NULL OR b.category = p_category)
+ AND (p_type IS NULL OR b.type = p_type)
+ AND (p_pm IS NULL OR b.pm = p_pm)
+ AND (p_start_time IS NULL OR b.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR b.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(b.title, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ b.id,
+ b.uid,
+ b.link_id,
+ b.pm,
+ b.title,
+ b.category,
+ b.type,
+ b.number,
+ b.balance,
+ b.mark,
+ b.status,
+ b.created_at,
+ b.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_user_bill b
+ LEFT JOIN public.ak_users u ON u.id = b.uid
+ WHERE (p_category IS NULL OR b.category = p_category)
+ AND (p_type IS NULL OR b.type = p_type)
+ AND (p_pm IS NULL OR b.pm = p_pm)
+ AND (p_start_time IS NULL OR b.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR b.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(b.title, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY b.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_user_bill_list IS '管理员资金流水列表分页查询';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除客服账号(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:account:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:account:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_accounts
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_account_delete IS '管理员删除客服账号';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取客服账号列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_accounts ka
+ JOIN public.ak_users u ON u.id = ka.user_id
+ WHERE (p_status IS NULL OR ka.status = p_status)
+ AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ ka.id,
+ ka.user_id,
+ ka.nickname,
+ ka.avatar,
+ ka.status,
+ ka.is_online,
+ ka.created_at,
+ ka.updated_at,
+ u.username as user_account
+ FROM public.ml_kefu_accounts ka
+ JOIN public.ak_users u ON u.id = ka.user_id
+ WHERE (p_status IS NULL OR ka.status = p_status)
+ AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%')
+ ORDER BY ka.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增或更新客服账号
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_save(
+ p_id UUID DEFAULT NULL,
+ p_user_id UUID DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_user_id IS NULL OR p_nickname IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields';
+ END IF;
+
+ INSERT INTO public.ml_kefu_accounts (
+ user_id, nickname, avatar, status
+ ) VALUES (
+ p_user_id, p_nickname, p_avatar, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_accounts
+ SET
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Account not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_set_status
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换客服账号启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_accounts
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_account_set_status IS '管理员设置客服账号状态';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除客服自动回复配置(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:auto_reply:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:auto_reply:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_auto_replies
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_delete IS '管理员删除客服自动回复配置';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取客服自动回复配置列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_auto_replies
+ WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, keyword, content, reply_type, status,
+ created_at, updated_at
+ FROM public.ml_kefu_auto_replies
+ WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_list IS '管理员分页查询客服自动回复列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新自动回复配置
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_save(
+ p_id UUID DEFAULT NULL,
+ p_keyword TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_reply_type TEXT DEFAULT 'text',
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_keyword IS NULL OR p_content IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: keyword or content';
+ END IF;
+
+ INSERT INTO public.ml_kefu_auto_replies (
+ keyword, content, reply_type, status
+ ) VALUES (
+ p_keyword, p_content, p_reply_type, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_auto_replies
+ SET
+ keyword = COALESCE(p_keyword, keyword),
+ content = COALESCE(p_content, content),
+ reply_type = COALESCE(p_reply_type, reply_type),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Auto reply record not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_save IS '管理员新增或更新自动回复配置';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_set_status
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换客服自动回复配置启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_auto_replies
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_set_status IS '管理员设置客服自动回复状态';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_feedback_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户留言反馈列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_feedbacks f
+ LEFT JOIN public.ak_users u ON u.id = f.user_id
+ WHERE (p_status IS NULL OR f.status = p_status)
+ AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ f.id,
+ f.user_id,
+ f.nickname,
+ f.phone,
+ f.content,
+ f.status,
+ f.reply_content,
+ f.processed_at,
+ f.created_at,
+ f.updated_at,
+ u.username as user_account
+ FROM public.ml_kefu_feedbacks f
+ LEFT JOIN public.ak_users u ON u.id = f.user_id
+ WHERE (p_status IS NULL OR f.status = p_status)
+ AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%')
+ ORDER BY f.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_list IS '管理员分页查询用户留言反馈列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_feedback_process
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端处理用户留言反馈(回复内容并更新状态)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_process(
+ p_id UUID,
+ p_reply_content TEXT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_feedbacks
+ SET
+ status = 1, -- 已处理
+ reply_content = p_reply_content,
+ processed_at = now(),
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_process IS '管理员处理并回复用户留言反馈';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除话术分类(支持级联软删除话术)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:word:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:word:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先标记该分类下的话术为删除
+ UPDATE public.ml_kefu_words
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 执行软删除分类本身:标记 deleted_at
+ UPDATE public.ml_kefu_word_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_delete IS '管理员删除话术分类';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取话术分类列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_list()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取分类列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT id, name, sort, created_at, updated_at
+ FROM public.ml_kefu_word_categories
+ ORDER BY sort ASC, created_at DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_list IS '管理员获取话术分类列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新话术分类
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name';
+ END IF;
+
+ INSERT INTO public.ml_kefu_word_categories (
+ name, sort
+ ) VALUES (
+ p_name, p_sort
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_word_categories
+ SET
+ name = COALESCE(p_name, name),
+ sort = COALESCE(p_sort, sort),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Category not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_save IS '管理员新增或更新话术分类';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除快捷话术(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:word:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:word:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_words
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_delete IS '管理员删除快捷话术';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取指定分类下的快捷话术列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_list(
+ p_category_id UUID DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取话术列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ w.id,
+ w.category_id,
+ w.title,
+ w.content,
+ w.sort,
+ w.created_at,
+ w.updated_at,
+ c.name as category_name
+ FROM public.ml_kefu_words w
+ JOIN public.ml_kefu_word_categories c ON c.id = w.category_id
+ WHERE (p_category_id IS NULL OR w.category_id = p_category_id)
+ AND (p_search IS NULL OR w.title ILIKE '%' || p_search || '%' OR w.content ILIKE '%' || p_search || '%')
+ ORDER BY w.sort ASC, w.created_at DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_list IS '管理员获取快捷话术列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新快捷话术
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_save(
+ p_id UUID DEFAULT NULL,
+ p_category_id UUID DEFAULT NULL,
+ p_title TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_category_id IS NULL OR p_title IS NULL OR p_content IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields';
+ END IF;
+
+ INSERT INTO public.ml_kefu_words (
+ category_id, title, content, sort
+ ) VALUES (
+ p_category_id, p_title, p_content, p_sort
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_words
+ SET
+ category_id = COALESCE(p_category_id, category_id),
+ title = COALESCE(p_title, title),
+ content = COALESCE(p_content, content),
+ sort = COALESCE(p_sort, sort),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Word not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_save IS '管理员新增或更新快捷话术';
+-- RPC: rpc_admin_get_integral_stats
+-- 位置:docs/sql/30_rpc/marketing/rpc_admin_get_integral_stats_v1.sql
+-- 说明:聚合统计积分概况(总额、趋势、分布)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_integral_stats(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_stats RECORD;
+ v_trend_data JSONB;
+ v_source_dist JSONB;
+ v_consume_dist JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算核心指标 (所有时间)
+ SELECT
+ COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE -number END), 0) as current_total,
+ COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE 0 END), 0) as cumulative_income,
+ COALESCE(SUM(CASE WHEN pm = 0 THEN number ELSE 0 END), 0) as cumulative_expend
+ INTO v_total_stats
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND status = 1;
+
+ -- 3. 趋势数据 (按日聚合)
+ SELECT jsonb_agg(t) INTO v_trend_data
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'MM-DD') AS date_group,
+ COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
+ WHERE b.category = 'integral' AND b.pm = 1 AND b.status = 1
+ AND date_trunc('day', b.created_at) = gs.day), 0) as income,
+ COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
+ WHERE b.category = 'integral' AND b.pm = 0 AND b.status = 1
+ AND date_trunc('day', b.created_at) = gs.day), 0) as expend
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ ORDER BY gs.day ASC
+ ) t;
+
+ -- 4. 来源分布 (按 type 分组)
+ SELECT jsonb_agg(t) INTO v_source_dist
+ FROM (
+ SELECT
+ type as label,
+ SUM(number) as value,
+ ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_income, 0)), 2) as percent
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND pm = 1 AND status = 1
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ -- 5. 消耗分布 (按 type 分组)
+ SELECT jsonb_agg(t) INTO v_consume_dist
+ FROM (
+ SELECT
+ type as label,
+ SUM(number) as value,
+ ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_expend, 0)), 2) as percent
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND pm = 0 AND status = 1
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'totals', jsonb_build_object(
+ 'current', v_total_stats.current_total,
+ 'income', v_total_stats.cumulative_income,
+ 'expend', v_total_stats.cumulative_expend
+ ),
+ 'trend', COALESCE(v_trend_data, '[]'::jsonb),
+ 'sources', COALESCE(v_source_dist, '[]'::jsonb),
+ 'consumes', COALESCE(v_consume_dist, '[]'::jsonb)
+ );
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_integral_stats(timestamptz, timestamptz) TO authenticated;
+-- =====================================================================================
+-- Admin 订单功能 - 收银台订单列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_cashier_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search_order_no TEXT DEFAULT NULL,
+ p_search_username TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 总数:仅已支付订单
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE o.paid_at IS NOT NULL
+ AND (p_search_order_no IS NULL OR o.order_no ILIKE '%' || p_search_order_no || '%')
+ AND (p_search_username IS NULL OR u.username ILIKE '%' || p_search_username || '%');
+
+ -- 3. 明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.discount_amount,
+ o.paid_at,
+ u.username as customer_name,
+ u.phone as customer_phone
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE o.paid_at IS NOT NULL
+ AND (p_search_order_no IS NULL OR o.order_no ILIKE '%' || p_search_order_no || '%')
+ AND (p_search_username IS NULL OR u.username ILIKE '%' || p_search_username || '%')
+ ORDER BY o.paid_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 订单管理 - 主订单列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_order_status INTEGER DEFAULT NULL,
+ p_search TEXT DEFAULT NULL,
+ p_start_time TIMESTAMPTZ DEFAULT NULL,
+ p_end_time TIMESTAMPTZ DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (依赖 public.ak_users.role)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE (p_order_status IS NULL OR o.order_status = p_order_status)
+ AND (p_start_time IS NULL OR o.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.created_at <= p_end_time)
+ AND (
+ p_search IS NULL
+ OR o.order_no ILIKE '%' || p_search || '%'
+ OR u.username ILIKE '%' || p_search || '%'
+ OR u.phone ILIKE '%' || p_search || '%'
+ );
+
+ -- 3. 获取明细列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.paid_amount,
+ o.discount_amount,
+ o.order_status,
+ o.payment_status,
+ o.shipping_status,
+ o.pay_type,
+ o.channel_type,
+ o.paid_at,
+ o.created_at,
+ u.username as buyer_name,
+ u.phone as buyer_phone,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url,
+ 'quantity', oi.quantity
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = o.id
+ ORDER BY oi.created_at ASC
+ LIMIT 1
+ ) as first_item_summary
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE (p_order_status IS NULL OR o.order_status = p_order_status)
+ AND (p_start_time IS NULL OR o.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.created_at <= p_end_time)
+ AND (
+ p_search IS NULL
+ OR o.order_no ILIKE '%' || p_search || '%'
+ OR u.username ILIKE '%' || p_search || '%'
+ OR u.phone ILIKE '%' || p_search || '%'
+ )
+ ORDER BY o.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 订单来源分布 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:统计订单来源分布。
+-- 注意:当前 ml_orders DDL 未包含来源/渠道字段,本函数提供最小可用兜底:统一返回 "unknown" 汇总。
+-- 若后续新增 channel/payment_method 等字段,可在此函数中替换为按渠道分组统计。
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_source_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按渠道类型聚合统计(排除已取消)
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ CASE o.channel_type
+ WHEN 1 THEN '公众号'
+ WHEN 2 THEN '小程序'
+ WHEN 3 THEN 'H5'
+ WHEN 4 THEN 'PC'
+ WHEN 5 THEN 'APP'
+ ELSE '其他'
+ END AS source,
+ COUNT(*) AS order_count,
+ COALESCE(SUM(o.total_amount), 0) AS total_amount
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time
+ AND o.created_at <= p_end_time
+ AND o.order_status != 5
+ GROUP BY o.channel_type
+ ORDER BY order_count DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 核心 KPI 汇总 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取指定时间段内的订单量、销售额、退款数及退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_order_count BIGINT;
+ v_total_amount DECIMAL(12,2);
+ v_refund_count BIGINT;
+ v_refund_amount DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计订单汇总(不含已取消)
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(total_amount), 0)
+ INTO v_order_count, v_total_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND order_status != 5; -- 5: 已取消
+
+ -- 3. 统计退款汇总
+ -- 注意:这里基于 ml_orders 的 payment_status 或 order_status 判断已退款
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(discount_amount), 0) -- 暂时用这个,若有真实退款金额字段请替换
+ INTO v_refund_count, v_refund_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND order_status IN (6, 7); -- 6: 退款中, 7: 已退款
+
+ RETURN jsonb_build_object(
+ 'order_count', v_order_count,
+ 'total_amount', v_total_amount,
+ 'refund_count', v_refund_count,
+ 'refund_amount', v_refund_amount
+ );
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 趋势统计 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按天聚合指定时间范围内的订单量/销售额/退款量/退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_trend(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ,
+ p_group_by TEXT DEFAULT 'day'
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 目前仅支持 day
+ IF p_group_by IS NULL OR p_group_by != 'day' THEN
+ RAISE EXCEPTION 'Unsupported group_by';
+ END IF;
+
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', o.created_at), 'YYYY-MM-DD') AS date_group,
+ COUNT(*) FILTER (WHERE o.order_status != 5) AS order_count,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.order_status != 5), 0) AS total_amount,
+ COUNT(*) FILTER (WHERE o.order_status IN (6, 7)) AS refund_count,
+ COALESCE(SUM(o.discount_amount) FILTER (WHERE o.order_status IN (6, 7)), 0) AS refund_amount
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time
+ AND o.created_at <= p_end_time
+ GROUP BY date_trunc('day', o.created_at)
+ ORDER BY date_trunc('day', o.created_at) ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 订单类型分布统计 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按订单类型(普通、收银、核销)统计指定时间段内的销售额及其占比
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_type_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_amount DECIMAL(12,2);
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算总销售额(用于算占比)
+ SELECT COALESCE(SUM(total_amount), 0) INTO v_total_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time AND created_at <= p_end_time
+ AND order_status != 5; -- 排除已取消
+
+ -- 3. 按类型统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ CASE o.order_type
+ WHEN 1 THEN '普通订单'
+ WHEN 2 THEN '收银订单'
+ WHEN 3 THEN '核销订单'
+ ELSE '其他类型'
+ END AS name,
+ COALESCE(SUM(o.total_amount), 0) AS amount,
+ CASE
+ WHEN v_total_amount > 0 THEN ROUND((COALESCE(SUM(o.total_amount), 0) / v_total_amount * 100), 2)
+ ELSE 0
+ END AS rate
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time AND o.created_at <= p_end_time
+ AND o.order_status != 5
+ GROUP BY o.order_type
+ ORDER BY amount DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单功能 - 售后退款列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_refund_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_refund_status INTEGER DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_refund_orders ro
+ WHERE (p_refund_status IS NULL OR ro.refund_status = p_refund_status)
+ AND (p_search IS NULL OR (
+ ro.refund_no ILIKE '%' || p_search || '%' OR
+ EXISTS (
+ SELECT 1 FROM public.ml_orders o
+ WHERE o.id = ro.order_id AND o.order_no ILIKE '%' || p_search || '%'
+ )
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ ro.id,
+ ro.refund_no,
+ ro.refund_amount,
+ ro.refund_status,
+ ro.refund_reason,
+ ro.applied_at,
+ o.order_no,
+ o.order_status,
+ u.username as customer_name,
+ u.phone as customer_phone,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = ro.order_id
+ LIMIT 1
+ ) as product_summary
+ FROM public.ml_refund_orders ro
+ LEFT JOIN public.ml_orders o ON ro.order_id = o.id
+ LEFT JOIN public.ak_users u ON ro.user_id = u.id
+ WHERE (p_refund_status IS NULL OR ro.refund_status = p_refund_status)
+ AND (p_search IS NULL OR (
+ ro.refund_no ILIKE '%' || p_search || '%' OR
+ o.order_no ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY ro.applied_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 订单功能 - 核销记录列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_write_off_record_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_verified_only BOOLEAN DEFAULT TRUE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数(核销订单类型 = 3)
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ WHERE o.order_type = 3
+ AND (p_verified_only = FALSE OR o.verified_at IS NOT NULL)
+ AND (p_search IS NULL OR o.order_no ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.payment_status,
+ o.order_status,
+ o.created_at,
+ o.verified_at,
+ buyer.username as customer_name,
+ buyer.phone as customer_phone,
+ verifier.username as verifier_name,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = o.id
+ LIMIT 1
+ ) as product_summary
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users buyer ON o.user_id = buyer.id
+ LEFT JOIN public.ak_users verifier ON o.verifier_id = verifier.id
+ WHERE o.order_type = 3
+ AND (p_verified_only = FALSE OR o.verified_at IS NOT NULL)
+ AND (p_search IS NULL OR o.order_no ILIKE '%' || p_search || '%')
+ ORDER BY o.verified_at DESC NULLS LAST, o.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 商品模块 - 删除分类 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1(支持级联软删除商品关联)
+-- 依赖:ml_categories, ml_products, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('product:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: product:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 检查是否有子分类 (方案 1)
+ IF EXISTS (
+ SELECT 1 FROM public.ml_categories
+ WHERE parent_id = p_id AND deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION '请先删除该分类下的子分类';
+ END IF;
+
+ -- 4. 级联软删除:该分类下的所有商品
+ UPDATE public.ml_products
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 5. 执行软删除分类本身
+ UPDATE public.ml_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ RETURN FOUND;
+END;
+$$;
+-- RPC: rpc_admin_get_product_reviews
+-- 作用:管理端分页获取商品评论列表,包含商品名称、用户名及规格
+-- 位置:docs/sql/30_rpc/product/rpc_admin_get_product_reviews_v1.sql
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_product_reviews(
+ p_search_product text DEFAULT NULL,
+ p_search_user text DEFAULT NULL,
+ p_status integer DEFAULT NULL,
+ p_start_time timestamptz DEFAULT NULL,
+ p_end_time timestamptz DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ id uuid,
+ product_id uuid,
+ product_name text,
+ product_image text,
+ user_id uuid,
+ username text,
+ rating integer,
+ content text,
+ merchant_reply text,
+ status integer,
+ created_at timestamptz,
+ total_count bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset integer := (p_page - 1) * p_page_size;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE ak_users.id = auth.uid() AND ak_users.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ RETURN QUERY
+ WITH filtered_reviews AS (
+ SELECT
+ r.*,
+ p.name as p_name,
+ p.main_image_url as p_image,
+ u.username as u_name,
+ COUNT(*) OVER() as full_count
+ FROM public.ml_product_reviews r
+ LEFT JOIN public.ml_products p ON r.product_id = p.id
+ LEFT JOIN public.ak_users u ON r.user_id = u.id
+ WHERE (p_search_product IS NULL OR p.name ILIKE '%' || p_search_product || '%')
+ AND (p_search_user IS NULL OR u.username ILIKE '%' || p_search_user || '%')
+ AND (p_status IS NULL OR r.status = p_status)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ )
+ SELECT
+ fr.id,
+ fr.product_id,
+ fr.p_name as product_name,
+ fr.p_image as product_image,
+ fr.user_id,
+ fr.u_name as username,
+ fr.rating,
+ fr.content,
+ fr.merchant_reply,
+ fr.status,
+ fr.created_at,
+ fr.full_count as total_count
+ FROM filtered_reviews fr
+ ORDER BY fr.created_at DESC
+ LIMIT p_page_size OFFSET v_offset;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_product_reviews(text, text, integer, timestamptz, timestamptz, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_product_reviews(text, text, integer, timestamptz, timestamptz, integer, integer) TO authenticated;
+-- =====================================================================================
+-- Admin 商品模块 - 商品统计概况 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_products, ml_orders, ml_browse_history, ak_users
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_stats(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_stats JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计核心指标
+ -- 商品浏览量 (PV), 访客数 (UV), 支付件数, 支付金额, 退款件数, 退款金额
+ WITH stats AS (
+ SELECT
+ (SELECT COALESCE(SUM(browse_duration), 0) FROM public.ml_browse_history WHERE created_at BETWEEN p_start_time AND p_end_time) as total_views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history WHERE created_at BETWEEN p_start_time AND p_end_time) as total_visitors,
+ (SELECT COALESCE(SUM(quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_count,
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders
+ WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status = 7) as refund_count,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status = 7) as refund_amount
+ )
+ SELECT jsonb_build_object(
+ 'views', total_views,
+ 'visitors', total_visitors,
+ 'pay_count', pay_count,
+ 'pay_amount', pay_amount,
+ 'refund_count', refund_count,
+ 'refund_amount', refund_amount
+ ) INTO v_stats FROM stats;
+
+ RETURN v_stats;
+END;
+$$;
+
+-- =====================================================================================
+-- Admin 商品模块 - 商品排行 RPC
+-- =====================================================================================
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_ranking(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE,
+ p_sort_by TEXT DEFAULT 'sales', -- views, sales, amount
+ p_limit INTEGER DEFAULT 10
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取排行数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ p.id,
+ p.name,
+ p.main_image_url as image,
+ COALESCE(p.view_count, 0) as views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history bh WHERE bh.product_id = p.id AND bh.created_at BETWEEN p_start_time AND p_end_time) as visitors,
+ (SELECT COALESCE(SUM(quantity), 0) FROM public.ml_shopping_cart sc WHERE sc.product_id = p.id AND sc.created_at BETWEEN p_start_time AND p_end_time) as cart_count,
+ (SELECT COUNT(DISTINCT o.id) FROM public.ml_orders o JOIN public.ml_order_items oi ON o.id = oi.order_id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time) as order_count,
+ (SELECT COALESCE(SUM(oi.quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_count,
+ (SELECT COALESCE(SUM(oi.total_amount), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COUNT(*) FROM public.ml_user_favorites f WHERE f.target_id = p.id AND f.target_type = 1 AND f.created_at BETWEEN p_start_time AND p_end_time) as fav_count
+ FROM public.ml_products p
+ WHERE p.status != 4
+ ORDER BY
+ CASE
+ WHEN p_sort_by = 'views' THEN COALESCE(p.view_count, 0)
+ WHEN p_sort_by = 'sales' THEN (
+ SELECT COALESCE(SUM(oi.quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)
+ )
+ WHEN p_sort_by = 'amount' THEN (
+ SELECT COALESCE(SUM(oi.total_amount), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)
+ )
+ ELSE COALESCE(p.view_count, 0)
+ END DESC
+ LIMIT p_limit
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 商品管理 - 商品状态汇总统计 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:统计出售中、仓库中、草稿箱、回收站各状态的商品数量
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_count_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_result JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计各状态数量
+ -- status 定义:1:上架(出售中), 2:下架(仓库中), 3:草稿, 4:逻辑删除(回收站)
+ SELECT jsonb_build_object(
+ 'selling', COUNT(*) FILTER (WHERE status = 1),
+ 'warehouse', COUNT(*) FILTER (WHERE status = 2),
+ 'draft', COUNT(*) FILTER (WHERE status = 3),
+ 'recycle', COUNT(*) FILTER (WHERE status = 4)
+ ) INTO v_result
+ FROM public.ml_products;
+
+ RETURN v_result;
+END;
+$$;
+-- =====================================================================================
+-- Admin 商品统计 - 营业趋势统计 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按天聚合指定时间范围内的商品浏览量、访客量、支付金额及退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_trend(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按日聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'YYYY-MM-DD') AS date_group,
+ (SELECT COUNT(*) FROM public.ml_browse_history bh WHERE date_trunc('day', bh.created_at) = gs.day) as views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history bh WHERE date_trunc('day', bh.created_at) = gs.day) as visitors,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders o WHERE date_trunc('day', o.created_at) = gs.day AND o.order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders o WHERE date_trunc('day', o.created_at) = gs.day AND o.order_status = 7) as refund_amount
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ ORDER BY gs.day ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户分组(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:group:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:group:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_groups
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户分组列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_groups
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, remark, status,
+ created_at, updated_at, deleted_at
+ FROM public.ak_user_groups
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户分组(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_remark TEXT DEFAULT NULL,
+ p_status INT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_groups(
+ name, remark, status,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_remark, COALESCE(p_status, 1),
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_groups
+ SET
+ name = p_name,
+ remark = p_remark,
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户分组状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_groups
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户标签(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:label:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:label:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_labels
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户标签列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_labels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, color, remark, status,
+ created_at, updated_at, deleted_at
+ FROM public.ak_user_labels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户标签(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_color TEXT DEFAULT NULL,
+ p_remark TEXT DEFAULT NULL,
+ p_status INT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_labels(
+ name, color, remark, status,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_color, p_remark, COALESCE(p_status, 1),
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_labels
+ SET
+ name = p_name,
+ color = p_color,
+ remark = p_remark,
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户标签状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_labels
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户等级(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:level:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:level:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_levels
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户等级列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_is_visible BOOLEAN DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (依赖 public.get_current_user_role())
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_levels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ AND (p_is_visible IS NULL OR is_visible = p_is_visible);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, level_weight, min_experience, discount_percent,
+ is_visible, status, icon_url, bg_image_url, bg_style_json,
+ remark, created_at, updated_at, deleted_at
+ FROM public.ak_user_levels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ AND (p_is_visible IS NULL OR is_visible = p_is_visible)
+ ORDER BY level_weight ASC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户等级(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_level_weight INT,
+ p_min_experience INT,
+ p_discount_percent INT,
+ p_is_visible BOOLEAN,
+ p_status INT,
+ p_icon_url TEXT DEFAULT NULL,
+ p_bg_image_url TEXT DEFAULT NULL,
+ p_bg_style_json JSONB DEFAULT NULL,
+ p_remark TEXT DEFAULT NULL
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验(最小化)
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ IF p_level_weight < 0 OR p_min_experience < 0 THEN
+ RAISE EXCEPTION 'Invalid level_weight or min_experience';
+ END IF;
+
+ IF p_discount_percent < 1 OR p_discount_percent > 100 THEN
+ RAISE EXCEPTION 'Invalid discount_percent';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_levels(
+ name, level_weight, min_experience, discount_percent,
+ is_visible, status,
+ icon_url, bg_image_url, bg_style_json,
+ remark,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_level_weight, p_min_experience, p_discount_percent,
+ p_is_visible, p_status,
+ p_icon_url, p_bg_image_url, p_bg_style_json,
+ p_remark,
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_levels
+ SET
+ name = p_name,
+ level_weight = p_level_weight,
+ min_experience = p_min_experience,
+ discount_percent = p_discount_percent,
+ is_visible = p_is_visible,
+ status = p_status,
+ icon_url = p_icon_url,
+ bg_image_url = p_bg_image_url,
+ bg_style_json = p_bg_style_json,
+ remark = p_remark,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户等级状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_levels
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_set_visible
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户等级是否展示
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_set_visible(
+ p_id UUID,
+ p_is_visible BOOLEAN
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_levels
+ SET is_visible = p_is_visible,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
diff --git a/docs/sql/all_schema_merged.sql b/docs/sql/all_schema_merged.sql
new file mode 100644
index 00000000..8fbd798a
--- /dev/null
+++ b/docs/sql/all_schema_merged.sql
@@ -0,0 +1,2150 @@
+-- =====================================================================================
+-- Migration: 全量软删除 (Soft Delete) 基础设施补齐
+-- 位置:docs/sql/10_schema/99_soft_delete_migration_v1.sql
+-- 对象类型:ALTER TABLE
+-- 说明:为所有核心业务表补齐 deleted_at, deleted_by, restored_at, restored_by 字段
+-- 涵盖:权限、配置、内容、装修、物流、分销、财务、客服、营销、商品、用户、订单
+-- =====================================================================================
+
+DO $$
+DECLARE
+ -- 需补齐软删除字段的业务表全量清单
+ t_names TEXT[] := ARRAY[
+ -- 1. 系统与权限 (Auth/System)
+ 'ak_roles', 'ak_permissions', 'ak_admin_roles', 'ak_role_permissions', 'ml_system_configs',
+
+ -- 2. 内容与装修 (CMS/Decoration)
+ 'ml_articles', 'ml_article_categories', 'ak_diy_pages',
+
+ -- 3. 物流资源 (Delivery)
+ 'ml_delivery_staff', 'ml_delivery_stations', 'ak_shipping_templates',
+
+ -- 4. 分销体系 (Distribution)
+ 'ak_distribution_agents', 'ak_distribution_divisions',
+ 'ak_distribution_agent_applications', 'ak_distribution_division_applications',
+ 'ak_promoter_relations', 'ak_commission_logs', 'ak_distribution_level', 'ak_distribution_config',
+
+ -- 5. 财务管理 (Finance)
+ 'ml_extract', 'ml_invoices', 'ml_user_bill', 'ml_user_recharge',
+
+ -- 6. 客服系统 (Kefu)
+ 'ml_kefu_accounts', 'ml_kefu_words', 'ml_kefu_word_categories', 'ml_kefu_auto_replies', 'ml_kefu_feedbacks',
+
+ -- 7. 营销活动 (Marketing)
+ 'ak_advanced_marketing', 'ak_bargain_groupbuy', 'ak_live_products', 'ak_lottery_live',
+ 'ak_marketing_checkin_configs', 'ak_marketing_newcomer_config', 'ak_marketing_signin_logs',
+ 'ak_member_management', 'ak_recharge_management', 'ak_signin_configs',
+
+ -- 8. 商品中心 (Product)
+ 'ml_products', 'ml_product_skus', 'ml_categories',
+ 'ak_product_labels', 'ak_product_member_prices', 'ak_product_protections', 'ak_product_templates',
+
+ -- 9. 用户管理 (User)
+ 'ak_user_labels', 'ak_user_groups', 'ak_user_levels', 'ak_users',
+
+ -- 10. 订单中心 (Order)
+ 'ml_orders'
+ ];
+ t_name TEXT;
+BEGIN
+ FOREACH t_name IN ARRAY t_names LOOP
+ -- 检查表是否存在
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = t_name) THEN
+
+ -- 1. 增加 deleted_at 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'deleted_at') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL', t_name);
+ END IF;
+
+ -- 2. 增加 deleted_by 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'deleted_by') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL', t_name);
+ END IF;
+
+ -- 3. 增加 restored_at 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'restored_at') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN restored_at TIMESTAMPTZ DEFAULT NULL', t_name);
+ END IF;
+
+ -- 4. 增加 restored_by 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'restored_by') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN restored_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL', t_name);
+ END IF;
+
+ -- 5. 建立软删除索引
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (deleted_at) WHERE deleted_at IS NULL', 'idx_' || t_name || '_soft_delete', t_name);
+
+ END IF;
+ END LOOP;
+END $$;
+-- =====================================================================================
+-- Schema: 系统配置表
+-- 位置:docs/sql/10_schema/admin/ml_system_configs_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:统一存储系统、应用、维护等模块的 Key-Value 配置项
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_system_configs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ config_key TEXT UNIQUE NOT NULL,
+ config_value JSONB NOT NULL DEFAULT '{}'::jsonb,
+ description TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_system_configs_key ON public.ml_system_configs (config_key);
+
+-- 注释
+COMMENT ON TABLE public.ml_system_configs IS '系统全局配置表';
+COMMENT ON COLUMN public.ml_system_configs.config_key IS '配置唯一标识键';
+COMMENT ON COLUMN public.ml_system_configs.config_value IS '配置内容 (JSONB)';
+-- =====================================================================================
+-- Migration: Auth 安全约束增强
+-- 位置:docs/sql/10_schema/auth/ak_auth_security_constraints_v1.sql
+-- 对象类型:ALTER TABLE / CONSTRAINT
+-- 说明:增强 ak_users 与 auth.users 的关联安全性,防止孤儿数据
+-- =====================================================================================
+
+-- 1. 确保 ak_users.auth_id 存在外键约束指向 auth.users
+-- 注意:Supabase 的 auth.users 表在 auth schema 下,需要确保权限正确
+DO $$
+BEGIN
+ -- 检查是否已存在外键约束
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_users_auth_id'
+ AND table_name = 'ak_users'
+ ) THEN
+ -- 添加外键约束,当 auth.users 被删除时自动删除对应的 profile
+ ALTER TABLE public.ak_users
+ ADD CONSTRAINT fk_ak_users_auth_id
+ FOREIGN KEY (auth_id) REFERENCES auth.users(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+-- 2. 为 auth_id 建立唯一索引,确保一个 auth 用户只有一个 profile
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ak_users_auth_id_unique
+ON public.ak_users(auth_id);
+
+-- 3. 为 role 字段建立索引,加速权限查询
+CREATE INDEX IF NOT EXISTS idx_ak_users_role
+ON public.ak_users(role);
+
+-- 4. 添加检查约束,确保 role 字段只能是有效值
+ALTER TABLE public.ak_users
+DROP CONSTRAINT IF EXISTS chk_ak_users_role_valid;
+
+ALTER TABLE public.ak_users
+ADD CONSTRAINT chk_ak_users_role_valid
+CHECK (role IN ('user', 'admin', 'staff', 'agent', 'kefu') OR role IS NULL);
+
+-- 5. 为 ak_admin_roles 添加约束确保关联有效性
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_admin_roles_admin_id'
+ AND table_name = 'ak_admin_roles'
+ ) THEN
+ ALTER TABLE public.ak_admin_roles
+ ADD CONSTRAINT fk_ak_admin_roles_admin_id
+ FOREIGN KEY (admin_id) REFERENCES public.ak_users(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+-- 6. 为 ak_role_permissions 添加约束确保关联有效性
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_role_permissions_role_id'
+ AND table_name = 'ak_role_permissions'
+ ) THEN
+ ALTER TABLE public.ak_role_permissions
+ ADD CONSTRAINT fk_ak_role_permissions_role_id
+ FOREIGN KEY (role_id) REFERENCES public.ak_roles(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_role_permissions_permission_id'
+ AND table_name = 'ak_role_permissions'
+ ) THEN
+ ALTER TABLE public.ak_role_permissions
+ ADD CONSTRAINT fk_ak_role_permissions_permission_id
+ FOREIGN KEY (permission_id) REFERENCES public.ak_permissions(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;-- =====================================================================================
+-- Schema: 内容管理模块核心表
+-- 位置:docs/sql/10_schema/cms/ml_cms_tables_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:包含文章分类及文章主表定义
+-- =====================================================================================
+
+-- 1. 文章分类表
+CREATE TABLE IF NOT EXISTS public.ml_article_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ icon TEXT NULL,
+ sort INTEGER NOT NULL DEFAULT 0,
+ status SMALLINT NOT NULL DEFAULT 1, -- 1: 启用, 0: 禁用
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 唯一性约束
+CREATE UNIQUE INDEX IF NOT EXISTS ml_article_categories_name_uniq ON public.ml_article_categories (name);
+
+-- 2. 文章主表
+CREATE TABLE IF NOT EXISTS public.ml_articles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category_id UUID NOT NULL REFERENCES public.ml_article_categories(id),
+
+ title TEXT NOT NULL,
+ author TEXT NULL,
+ image TEXT NULL, -- 文章封面图
+ description TEXT NULL, -- 文章简介
+ content TEXT NOT NULL, -- 文章内容 (富文本)
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0: 未发布, 1: 已发布
+ views INTEGER NOT NULL DEFAULT 0, -- 浏览量
+
+ is_banner BOOLEAN NOT NULL DEFAULT FALSE, -- 是否展示在 banner
+ is_hot BOOLEAN NOT NULL DEFAULT FALSE, -- 是否热门
+
+ linked_product_id UUID NULL, -- 关联商品ID (可选)
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ml_articles_category_id_idx ON public.ml_articles (category_id);
+CREATE INDEX IF NOT EXISTS ml_articles_status_idx ON public.ml_articles (status);
+CREATE INDEX IF NOT EXISTS ml_articles_created_at_idx ON public.ml_articles (created_at DESC);
+-- =====================================================================================
+-- Schema: 装修模块 - DIY 页面配置表
+-- 位置:docs/sql/10_schema/decoration/ak_diy_pages_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:存储首页、专题页及个人中心的 DIY 布局 JSON 配置
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_diy_pages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ type TEXT NOT NULL, -- home: 首页, topic: 专题页, user: 个人中心
+
+ config JSONB NOT NULL DEFAULT '{}'::jsonb, -- 核心布局配置 (组件列表及参数)
+
+ is_home BOOLEAN NOT NULL DEFAULT FALSE, -- 是否为生效首页
+ is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_diy_pages_type ON public.ak_diy_pages (type);
+CREATE INDEX IF NOT EXISTS idx_diy_pages_is_home ON public.ak_diy_pages (is_home) WHERE is_home = TRUE;
+
+-- 注释
+COMMENT ON TABLE public.ak_diy_pages IS 'DIY 页面装修配置表';
+COMMENT ON COLUMN public.ak_diy_pages.type IS '页面类型: home(首页), topic(专题), user(个人中心)';
+COMMENT ON COLUMN public.ak_diy_pages.config IS 'DIY 布局配置 JSON';
+-- =====================================================================================
+-- Schema: 物流设置 (Delivery) 核心表
+-- 位置:docs/sql/10_schema/delivery/ak_delivery_system_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:包含配送员管理表、提货点/核销点管理表
+-- =====================================================================================
+
+-- 1. 配送员管理表
+CREATE TABLE IF NOT EXISTS public.ml_delivery_staff (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID REFERENCES public.ak_users(id) ON DELETE SET NULL, -- 关联用户(可选)
+
+ nickname TEXT NOT NULL, -- 配送员名称
+ avatar TEXT, -- 头像
+ phone TEXT NOT NULL, -- 手机号
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-启用, 0-禁用
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 提货点/核销点管理表
+CREATE TABLE IF NOT EXISTS public.ml_delivery_stations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL, -- 提货点名称
+ phone TEXT NOT NULL, -- 联系电话
+ address TEXT NOT NULL, -- 详细地址
+
+ image TEXT, -- 门店图片
+ lng NUMERIC(10,7), -- 经度
+ lat NUMERIC(10,7), -- 纬度
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-显示, 0-隐藏
+ sort_order INTEGER DEFAULT 0,
+
+ business_hours JSONB, -- 营业时间 (如: {"start": "09:00", "end": "21:00"})
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_delivery_staff_phone ON public.ml_delivery_staff(phone);
+CREATE INDEX IF NOT EXISTS idx_delivery_stations_status ON public.ml_delivery_stations(status);
+
+-- 注释
+COMMENT ON TABLE public.ml_delivery_staff IS '配送员信息表';
+COMMENT ON TABLE public.ml_delivery_stations IS '提货点/核销点信息表';
+-- =====================================================================================
+-- Schema: 医养执行端 Delivery 基础表升级
+-- 位置:docs/sql/10_schema/delivery/ak_delivery_system_v2.sql
+-- 对象类型:ALTER TABLE / INDEX / TRIGGER
+-- 版本:v2
+-- 说明:在 v1 基础上最小升级为“医养上门服务执行端”可用模型。
+-- 补齐 uid 唯一索引、软删除、机构关联、staff_no、在线状态、资质状态。
+-- =====================================================================================
+
+-- 0. 通用 updated_at 触发器
+CREATE OR REPLACE FUNCTION public.tg_set_updated_at()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$;
+
+-- 1. 配送/执行人员表升级
+ALTER TABLE public.ml_delivery_staff
+ ADD COLUMN IF NOT EXISTS station_id UUID REFERENCES public.ml_delivery_stations(id) ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS staff_no TEXT,
+ ADD COLUMN IF NOT EXISTS online_status TEXT NOT NULL DEFAULT 'resting',
+ ADD COLUMN IF NOT EXISTS certificate_status TEXT NOT NULL DEFAULT 'pending',
+ ADD COLUMN IF NOT EXISTS certificate_expire_at DATE,
+ ADD COLUMN IF NOT EXISTS service_area TEXT NOT NULL DEFAULT '',
+ ADD COLUMN IF NOT EXISTS skills JSONB NOT NULL DEFAULT '[]'::jsonb,
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL;
+
+ALTER TABLE public.ml_delivery_staff
+ DROP CONSTRAINT IF EXISTS chk_ml_delivery_staff_online_status;
+ALTER TABLE public.ml_delivery_staff
+ ADD CONSTRAINT chk_ml_delivery_staff_online_status
+ CHECK (online_status IN ('online', 'resting', 'busy'));
+
+ALTER TABLE public.ml_delivery_staff
+ DROP CONSTRAINT IF EXISTS chk_ml_delivery_staff_certificate_status;
+ALTER TABLE public.ml_delivery_staff
+ ADD CONSTRAINT chk_ml_delivery_staff_certificate_status
+ CHECK (certificate_status IN ('valid', 'expired', 'pending'));
+
+-- 2. 站点/机构表升级
+ALTER TABLE public.ml_delivery_stations
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL;
+
+-- 3. 索引
+CREATE UNIQUE INDEX IF NOT EXISTS uq_ml_delivery_staff_uid_active
+ ON public.ml_delivery_staff(uid)
+ WHERE uid IS NOT NULL AND deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_ml_delivery_staff_staff_no_active
+ ON public.ml_delivery_staff(staff_no)
+ WHERE staff_no IS NOT NULL AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_station_id
+ ON public.ml_delivery_staff(station_id)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_deleted_at
+ ON public.ml_delivery_staff(deleted_at);
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_status_active
+ ON public.ml_delivery_staff(status, is_active)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_stations_deleted_at
+ ON public.ml_delivery_stations(deleted_at);
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_stations_status_active
+ ON public.ml_delivery_stations(status)
+ WHERE deleted_at IS NULL;
+
+-- 4. 自动维护 updated_at
+DROP TRIGGER IF EXISTS trg_ml_delivery_staff_set_updated_at ON public.ml_delivery_staff;
+CREATE TRIGGER trg_ml_delivery_staff_set_updated_at
+BEFORE UPDATE ON public.ml_delivery_staff
+FOR EACH ROW
+EXECUTE FUNCTION public.tg_set_updated_at();
+
+DROP TRIGGER IF EXISTS trg_ml_delivery_stations_set_updated_at ON public.ml_delivery_stations;
+CREATE TRIGGER trg_ml_delivery_stations_set_updated_at
+BEFORE UPDATE ON public.ml_delivery_stations
+FOR EACH ROW
+EXECUTE FUNCTION public.tg_set_updated_at();
+
+-- 5. 注释
+COMMENT ON COLUMN public.ml_delivery_staff.station_id IS '所属机构/服务站点 ID';
+COMMENT ON COLUMN public.ml_delivery_staff.staff_no IS '服务人员编号';
+COMMENT ON COLUMN public.ml_delivery_staff.online_status IS '在线状态:online/resting/busy';
+COMMENT ON COLUMN public.ml_delivery_staff.certificate_status IS '资质状态:valid/expired/pending';
+COMMENT ON COLUMN public.ml_delivery_staff.certificate_expire_at IS '主要资质到期日期';
+COMMENT ON COLUMN public.ml_delivery_staff.service_area IS '服务区域描述';
+COMMENT ON COLUMN public.ml_delivery_staff.skills IS '技能标签 JSON 数组';
+COMMENT ON COLUMN public.ml_delivery_staff.deleted_at IS '软删除时间';
+COMMENT ON COLUMN public.ml_delivery_staff.deleted_by IS '软删除操作人';
+COMMENT ON COLUMN public.ml_delivery_stations.deleted_at IS '软删除时间';
+COMMENT ON COLUMN public.ml_delivery_stations.deleted_by IS '软删除操作人';
+-- 佣金流水表:记录每一笔佣金变动(冻结/可用/已提现/取消)
+CREATE TABLE IF NOT EXISTS public.ak_commission_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- 佣金获得者
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ -- 佣金来源用户(下单用户/被推广用户,可为空)
+ source_uid UUID REFERENCES public.ak_users(id) ON DELETE SET NULL,
+
+ -- 关联订单
+ order_id UUID REFERENCES public.ml_orders(id) ON DELETE SET NULL,
+ order_no VARCHAR(50),
+
+ -- 金额与状态
+ amount DECIMAL(12,2) NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'frozen', -- frozen/available/withdrawn/canceled
+
+ -- 冻结到期时间(用于解冻逻辑)
+ frozen_until TIMESTAMPTZ,
+
+ remark TEXT,
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_ak_commission_amount_nonneg CHECK (amount >= 0),
+ CONSTRAINT chk_ak_commission_status CHECK (status IN ('frozen','available','withdrawn','canceled'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_uid ON public.ak_commission_logs(uid);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_order_id ON public.ak_commission_logs(order_id);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_status ON public.ak_commission_logs(status);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_created_at ON public.ak_commission_logs(created_at);
+
+-- 启用 RLS
+ALTER TABLE public.ak_commission_logs ENABLE ROW LEVEL SECURITY;
+
+-- Admin 可读写
+CREATE POLICY "Admins can manage commission logs"
+ON public.ak_commission_logs
+FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 用户仅可查看自己的佣金流水
+CREATE POLICY "Users can view own commission logs"
+ON public.ak_commission_logs
+FOR SELECT
+TO authenticated
+USING (uid = auth.uid());
+-- =====================================================================================
+-- Schema: 分销代理商申请表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users, ak_distribution_divisions
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_agent_applications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid),
+
+ agent_name TEXT NOT NULL,
+ agent_phone TEXT NULL,
+
+ proof_images JSONB NULL, -- 申请凭证图片列表
+
+ status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
+ refusal_reason TEXT NULL,
+
+ approved_at TIMESTAMPTZ NULL,
+ approved_by UUID NULL REFERENCES public.ak_users(id),
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_uid ON public.ak_distribution_agent_applications(uid);
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_division_uid ON public.ak_distribution_agent_applications(division_uid);
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_status ON public.ak_distribution_agent_applications(status);
+
+COMMENT ON TABLE public.ak_distribution_agent_applications IS '分销代理商申请记录表';
+COMMENT ON COLUMN public.ak_distribution_agent_applications.proof_images IS '申请图片列表(JSON)';
+-- =====================================================================================
+-- Schema: 分销代理商管理表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users, ak_distribution_divisions
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_agents (
+ uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid), -- 所属事业部
+ name TEXT NOT NULL,
+ commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100),
+ is_enabled BOOLEAN DEFAULT TRUE,
+ end_time TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_distribution_agents_division_uid ON public.ak_distribution_agents(division_uid);
+
+-- 注释
+COMMENT ON TABLE public.ak_distribution_agents IS '分销代理商信息表';
+COMMENT ON COLUMN public.ak_distribution_agents.uid IS '用户ID(关联代理商本人)';
+COMMENT ON COLUMN public.ak_distribution_agents.division_uid IS '所属事业部UID';
+COMMENT ON COLUMN public.ak_distribution_agents.commission_ratio IS '代理商固定分佣比例(%)';
+-- 1. 创建分销配置表
+CREATE TABLE IF NOT EXISTS public.ak_distribution_config (
+ id TEXT PRIMARY KEY DEFAULT 'global_config',
+ is_enabled BOOLEAN DEFAULT true, -- 分销启用
+ extract_type TEXT DEFAULT '2', -- 分销模式: 1指定, 2人人, 3满额
+ bind_type TEXT DEFAULT '2', -- 绑定关系: 1所有用户, 2新用户
+ store_brokerage_binding_status TEXT DEFAULT '1', -- 绑定模式: 1永久, 2有效期, 3临时
+ brokerage_poster_status TEXT, -- 分销海报图路径/URL
+ brokerage_level INTEGER DEFAULT 2, -- 分销层级: 1, 2
+ is_area_manager BOOLEAN DEFAULT true, -- 事业部开关
+ is_agent_apply BOOLEAN DEFAULT true, -- 代理商申请开关
+ is_commission_window BOOLEAN DEFAULT true, -- 佣金悬浮窗开关
+
+ -- 返佣设置
+ is_self_brokerage BOOLEAN DEFAULT true, -- 自购返佣
+ is_member_brokerage BOOLEAN DEFAULT false, -- 购买会员返佣
+ brokerage_type TEXT DEFAULT '1', -- 返佣类型: 1价格, 2实付
+ is_promoter_brokerage BOOLEAN DEFAULT true, -- 推广用户返佣
+ promoter_brokerage_price DECIMAL(10,2) DEFAULT 2.00,
+ promoter_brokerage_day_max DECIMAL(10,2) DEFAULT -1.00,
+ store_brokerage_ratio DECIMAL(10,2) DEFAULT 20.00,
+ store_brokerage_two_ratio DECIMAL(10,2) DEFAULT 2.00,
+ extract_frozen_time INTEGER DEFAULT 1,
+
+ -- 提现设置
+ user_extract_min_price DECIMAL(10,2) DEFAULT 1.00,
+ extract_bank_list TEXT DEFAULT '中国银行',
+ extract_type_list TEXT[] DEFAULT ARRAY['bank', 'wechat', 'alipay'],
+ wechat_extract_type TEXT DEFAULT '1',
+ alipay_extract_type TEXT DEFAULT '1',
+ user_extract_fee DECIMAL(10,2) DEFAULT 0.00,
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 2. 启用 RLS
+ALTER TABLE public.ak_distribution_config ENABLE ROW LEVEL SECURITY;
+
+-- 3. 创建权限策略 (基于公共角色函数或直接查询 ak_users)
+-- 允许 Admin 查看配置
+CREATE POLICY "Admins can view distribution config"
+ON public.ak_distribution_config FOR SELECT
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 允许 Admin 修改配置
+CREATE POLICY "Admins can update distribution config"
+ON public.ak_distribution_config FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 4. 插入初始化数据
+INSERT INTO public.ak_distribution_config (id)
+VALUES ('global_config')
+ON CONFLICT (id) DO NOTHING;
+-- =====================================================================================
+-- Schema: 分销事业部申请表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_division_applications_v1.sql
+-- 说明:记录用户申请加入事业部成为代理商的流水,支持审核流转,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_division_applications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ division_id UUID NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 申请人填写的代理商名称
+ phone TEXT NOT NULL, -- 申请人联系电话
+ images JSONB DEFAULT '[]'::jsonb, -- 申请附件图片 (数组)
+
+ status INTEGER DEFAULT 1, -- 状态: 1待审核, 2已同意, 3已拒绝
+ admin_remark TEXT, -- 审核备注
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_distribution_division_applications ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略
+CREATE POLICY "Merchants manage their own applications"
+ON public.ak_distribution_division_applications FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许用户提交和查看自己的申请
+CREATE POLICY "Users handle own applications"
+ON public.ak_distribution_division_applications FOR ALL
+TO authenticated
+USING (uid = auth.uid())
+WITH CHECK (uid = auth.uid());
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_div_app_merchant ON public.ak_distribution_division_applications(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_div_app_uid ON public.ak_distribution_division_applications(uid);
+CREATE INDEX IF NOT EXISTS idx_div_app_status ON public.ak_distribution_division_applications(status);
+-- =====================================================================================
+-- Schema: 分销事业部管理表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_divisions (
+ uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ invite_code TEXT UNIQUE NOT NULL,
+ commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100),
+ is_enabled BOOLEAN DEFAULT TRUE,
+ end_time TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_distribution_divisions_invite_code ON public.ak_distribution_divisions(invite_code);
+
+-- 注释
+COMMENT ON TABLE public.ak_distribution_divisions IS '分销事业部信息表';
+COMMENT ON COLUMN public.ak_distribution_divisions.uid IS '用户ID(关联事业部负责人)';
+COMMENT ON COLUMN public.ak_distribution_divisions.invite_code IS '事业部专属邀请码';
+COMMENT ON COLUMN public.ak_distribution_divisions.commission_ratio IS '事业部固定分佣比例(%)';
+COMMENT ON COLUMN public.ak_distribution_divisions.end_time IS '事业部有效截止时间';
+-- 1. 创建分销等级表
+CREATE TABLE IF NOT EXISTS public.ak_distribution_level (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL, -- 等级名称
+ level INTEGER NOT NULL UNIQUE, -- 等级权重/数字(如1, 2, 3)
+ percent1 DECIMAL(10,2) DEFAULT 0, -- 一级分佣比例 (%)
+ percent2 DECIMAL(10,2) DEFAULT 0, -- 二级分佣比例 (%)
+ task_total INTEGER DEFAULT 0, -- 任务总数
+ task_finish INTEGER DEFAULT 0, -- 需完成数量(升级门槛)
+ is_visible BOOLEAN DEFAULT true, -- 是否显示
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 启用 RLS
+ALTER TABLE public.ak_distribution_level ENABLE ROW LEVEL SECURITY;
+
+-- 3. 创建权限策略
+-- 允许所有认证用户查看等级(用于前端展示)
+CREATE POLICY "Anyone can view levels"
+ON public.ak_distribution_level FOR SELECT
+TO authenticated
+USING (true);
+
+-- 仅允许 Admin 进行管理 (INSERT/UPDATE/DELETE)
+CREATE POLICY "Admins can manage levels"
+ON public.ak_distribution_level FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 4. 插入初始化示例数据
+INSERT INTO public.ak_distribution_level (name, level, percent1, percent2, is_visible)
+VALUES ('普通分销员', 1, 10.00, 5.00, true)
+ON CONFLICT (level) DO NOTHING;
+-- 推广员关系表:记录下级与上级(邀请人)的绑定关系
+CREATE TABLE IF NOT EXISTS public.ak_promoter_relations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ inviter_uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ bind_time TIMESTAMPTZ DEFAULT now(),
+ created_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_ak_promoter_relations_no_self CHECK (uid <> inviter_uid),
+ CONSTRAINT uq_ak_promoter_relations_uid UNIQUE (uid)
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_promoter_relations_inviter_uid ON public.ak_promoter_relations(inviter_uid);
+
+-- 启用 RLS
+ALTER TABLE public.ak_promoter_relations ENABLE ROW LEVEL SECURITY;
+
+-- Admin 可读写
+CREATE POLICY "Admins can manage promoter relations"
+ON public.ak_promoter_relations
+FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 普通用户可查看与自己相关的关系(可选,便于移动端展示上级/下级)
+CREATE POLICY "Users can view their promoter relation"
+ON public.ak_promoter_relations
+FOR SELECT
+TO authenticated
+USING (uid = auth.uid() OR inviter_uid = auth.uid());
+-- =====================================================================================
+-- Schema: 用户提现申请表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:管理用户发起的提现申请(佣金/余额),支持多种提现方式及快照信息
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_extract (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ real_name TEXT NULL, -- 提现人姓名快照
+ extract_type TEXT NOT NULL, -- 提现方式: alipay, wechat, bank
+
+ -- 账号快照信息
+ alipay_code TEXT NULL, -- 支付宝账号
+ wechat_code TEXT NULL, -- 微信账号
+ bank_code TEXT NULL, -- 银行卡号
+ bank_address TEXT NULL, -- 开户行地址
+
+ extract_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 申请提现金额
+ service_fee DECIMAL(12,2) NOT NULL DEFAULT 0, -- 提现手续费
+ balance DECIMAL(12,2) NOT NULL DEFAULT 0, -- 提现时的余额快照
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 状态: 0:待审核, 1:已通过, -1:已驳回
+ refusal_reason TEXT NULL, -- 驳回原因
+
+ admin_id UUID NULL REFERENCES public.ak_users(id), -- 审核人ID
+ payment_time TIMESTAMPTZ NULL, -- 打款/到账时间
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_extract_uid_idx ON public.ml_extract (uid);
+CREATE INDEX IF NOT EXISTS ml_extract_status_idx ON public.ml_extract (status);
+CREATE INDEX IF NOT EXISTS ml_extract_created_at_idx ON public.ml_extract (created_at DESC);
+-- =====================================================================================
+-- Schema: 发票管理表
+-- 位置:docs/sql/10_schema/finance/ml_invoices_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户提交的开票申请及其处理状态
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_invoices (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+ order_no TEXT NOT NULL, -- 关联订单号
+
+ order_amount DECIMAL(12,2) NOT NULL, -- 订单金额
+
+ invoice_type SMALLINT NOT NULL DEFAULT 1, -- 1: 电子普通发票, 2: 增值税专用发票
+ header_type SMALLINT NOT NULL DEFAULT 1, -- 1: 个人, 2: 企业
+
+ header_name TEXT NOT NULL, -- 发票抬头
+ tax_id TEXT NULL, -- 企业税号
+
+ email TEXT NULL, -- 接收邮箱
+ remark TEXT NULL, -- 备注
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0: 待开票, 1: 已开票, -1: 已拒绝
+ refusal_reason TEXT NULL, -- 驳回原因
+ invoice_url TEXT NULL, -- 电子发票文件路径/URL
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_invoices_uid_idx ON public.ml_invoices (uid);
+CREATE INDEX IF NOT EXISTS ml_invoices_order_no_idx ON public.ml_invoices (order_no);
+CREATE INDEX IF NOT EXISTS ml_invoices_status_idx ON public.ml_invoices (status);
+-- =====================================================================================
+-- Schema: 用户资金流水表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户余额、积分、佣金的所有增减流水(原子日志)
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_user_bill (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ link_id TEXT NULL, -- 关联业务ID(订单号、提现ID、充值ID等)
+ pm SMALLINT NOT NULL DEFAULT 1, -- 0:支出, 1:收入
+
+ title TEXT NOT NULL, -- 流水标题(如:商品购买、充值、提现)
+ category TEXT NOT NULL, -- 业务大类(如:balance-余额, integral-积分, brokerage-佣金)
+ type TEXT NOT NULL, -- 业务子类型(如:recharge, extract, pay, refund, system_add, system_sub)
+
+ number DECIMAL(12,2) NOT NULL DEFAULT 0, -- 变动金额
+ balance DECIMAL(12,2) NOT NULL DEFAULT 0, -- 变动后的余额快照
+
+ mark TEXT NULL, -- 备注
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态(1:有效, 0:无效/冲正)
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ml_user_bill_uid_idx ON public.ml_user_bill (uid);
+CREATE INDEX IF NOT EXISTS ml_user_bill_category_type_idx ON public.ml_user_bill (category, type);
+CREATE INDEX IF NOT EXISTS ml_user_bill_created_at_idx ON public.ml_user_bill (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户充值记录表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户主动发起的充值申请及支付状态
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_user_recharge (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ order_no TEXT UNIQUE NOT NULL, -- 充值订单号(cz开头)
+ recharge_type TEXT NOT NULL, -- 充值渠道: wechat, alipay, system (后台补单)
+
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 实际充值金额
+ give_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 赠送金额
+
+ paid SMALLINT NOT NULL DEFAULT 0, -- 支付状态: 0:未支付, 1:已支付
+ pay_time TIMESTAMPTZ NULL, -- 支付时间
+
+ channel_trade_no TEXT NULL, -- 外部渠道流水号
+ status SMALLINT NOT NULL DEFAULT 1, -- 记录状态: 1:正常, 0:逻辑删除
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_user_recharge_uid_idx ON public.ml_user_recharge (uid);
+CREATE INDEX IF NOT EXISTS ml_user_recharge_order_no_idx ON public.ml_user_recharge (order_no);
+CREATE INDEX IF NOT EXISTS ml_user_recharge_created_at_idx ON public.ml_user_recharge (created_at DESC);
+-- =====================================================================================
+-- Schema: homecare foundation
+-- Version: v1
+-- Purpose: 为 ec_service_requests / ec_care_tasks / ec_care_records / hc_* 新链补齐最小结构、索引与基础 RLS。
+-- Coverage:
+-- 1. consumer 下单与自动派单字段
+-- 2. consumer 验收 / 退回整改 / 评价字段
+-- 3. delivery 执行记录、异常、证据、事件字段
+-- =====================================================================================
+
+-- 执行稿见:mall_sql/migrations/20260526_homecare_foundation_v1.sql-- =====================================================================================
+-- Schema: 客服模块核心表
+-- 位置:docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:包含客服账号、话术、留言及自动回复逻辑
+-- =====================================================================================
+
+-- 1. 客服人员表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_accounts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id), -- 关联主用户表
+
+ nickname TEXT NOT NULL, -- 客服昵称
+ avatar TEXT NULL, -- 客服头像
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 1:启用, 0:禁用
+ is_online BOOLEAN NOT NULL DEFAULT FALSE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 话术分类表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_word_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ sort INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 3. 客服快捷话术表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_words (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category_id UUID NOT NULL REFERENCES public.ml_kefu_word_categories(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ sort INT NOT NULL DEFAULT 0,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 4. 用户留言反馈表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_feedbacks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NULL REFERENCES public.ak_users(id), -- 允许匿名留言
+
+ nickname TEXT NULL,
+ phone TEXT NULL,
+ content TEXT NOT NULL,
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0:未处理, 1:已处理
+ reply_content TEXT NULL, -- 管理员回复内容
+ processed_at TIMESTAMPTZ NULL, -- 处理时间
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 5. 关键词自动回复表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_auto_replies (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ keyword TEXT NOT NULL,
+ content TEXT NOT NULL,
+ reply_type TEXT NOT NULL DEFAULT 'text', -- text, image
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 1:开启, 0:关闭
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_kefu_accounts_user_id_idx ON public.ml_kefu_accounts (user_id);
+CREATE INDEX IF NOT EXISTS ml_kefu_words_category_id_idx ON public.ml_kefu_words (category_id);
+CREATE INDEX IF NOT EXISTS ml_kefu_feedbacks_status_idx ON public.ml_kefu_feedbacks (status);
+CREATE INDEX IF NOT EXISTS ml_kefu_auto_replies_keyword_idx ON public.ml_kefu_auto_replies (keyword);
+-- =====================================================================================
+-- Schema: 秒杀与拼团活动表
+-- 位置:docs/sql/10_schema/marketing/ak_advanced_marketing_v1.sql
+-- 说明:管理秒杀活动与拼团活动,按商家隔离。
+-- =====================================================================================
+
+-- 1. 秒杀活动表
+CREATE TABLE IF NOT EXISTS public.ak_seckill_activities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ single_limit INTEGER DEFAULT 1, -- 单次限购
+ total_limit INTEGER DEFAULT 10, -- 总购买数量限制
+ product_count INTEGER DEFAULT 0, -- 包含商品数量
+ time_range TEXT NOT NULL, -- 活动时段 (如 "06:00-24:00")
+
+ start_date TIMESTAMPTZ NOT NULL, -- 开始日期
+ end_date TIMESTAMPTZ NOT NULL, -- 结束日期
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 拼团活动表 (开团记录)
+CREATE TABLE IF NOT EXISTS public.ak_combination_activities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ uid UUID NOT NULL REFERENCES public.ak_users(id), -- 开团团长
+ product_id UUID NOT NULL REFERENCES public.ml_products(id), -- 拼团商品
+
+ people INTEGER DEFAULT 2, -- 几人团
+ count_people INTEGER DEFAULT 1, -- 当前几人参加
+
+ start_time TIMESTAMPTZ DEFAULT now(), -- 开团时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status TEXT NOT NULL DEFAULT 'ongoing', -- ongoing进行中, pending未完成, ended已成功
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_comb_status CHECK (status IN ('ongoing', 'pending', 'ended'))
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_seckill_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_combination_activities ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 秒杀策略
+CREATE POLICY "Merchants can manage their own seckill activities"
+ON public.ak_seckill_activities FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 拼团策略
+CREATE POLICY "Merchants can manage their own combination activities"
+ON public.ak_combination_activities FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于移动端展示)
+CREATE POLICY "Anyone can view active marketing activities"
+ON public.ak_seckill_activities FOR SELECT
+TO authenticated
+USING (status = true);
+
+CREATE POLICY "Anyone can view ongoing combinations"
+ON public.ak_combination_activities FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema: 砍价与团购活动表
+-- 位置:docs/sql/10_schema/marketing/ak_bargain_groupbuy_v1.sql
+-- 说明:管理砍价与团购活动,按商家隔离。
+-- =====================================================================================
+
+-- 1. 砍价活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_bargains (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ min_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 砍价最低价
+ stock INTEGER DEFAULT 0, -- 活动库存
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 团购活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_groupbuys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 团购价格
+ people INTEGER DEFAULT 2, -- 成团人数要求
+ stock INTEGER DEFAULT 0, -- 活动库存
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_marketing_bargains ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_groupbuys ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 砍价策略
+CREATE POLICY "Merchants can manage their own bargains"
+ON public.ak_marketing_bargains FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 团购策略
+CREATE POLICY "Merchants can manage their own groupbuys"
+ON public.ak_marketing_groupbuys FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于移动端展示)
+CREATE POLICY "Anyone can view active marketing activities"
+ON public.ak_marketing_bargains FOR SELECT
+TO authenticated
+USING (status = true);
+
+CREATE POLICY "Anyone can view active groupbuys"
+ON public.ak_marketing_groupbuys FOR SELECT
+TO authenticated
+USING (status = true);
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_bargains_merchant ON public.ak_marketing_bargains(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_groupbuys_merchant ON public.ak_marketing_groupbuys(merchant_id);
+-- =====================================================================================
+-- Schema: 直播商品管理表
+-- 位置:docs/sql/10_schema/marketing/ak_live_products_v1.sql
+-- 说明:管理直播活动关联的商品,支持直播价设置与审核状态,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_products (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ live_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 直播专属价
+ stock INTEGER DEFAULT 0, -- 直播可用库存
+
+ audit_status INTEGER DEFAULT 1, -- 审核状态: 1待审核, 2审核通过, 3审核驳回
+ is_show BOOLEAN DEFAULT true, -- 是否在直播间显示
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_live_products ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的直播商品
+CREATE POLICY "Merchants can manage their own live products"
+ON public.ak_marketing_live_products FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看审核通过的商品
+CREATE POLICY "Anyone can view approved live products"
+ON public.ak_marketing_live_products FOR SELECT
+TO authenticated
+USING (audit_status = 2 AND is_show = true);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_live_products_merchant ON public.ak_marketing_live_products(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_live_products_product ON public.ak_marketing_live_products(product_id);
+-- =====================================================================================
+-- Schema: 抽奖与直播管理表
+-- 位置:docs/sql/10_schema/marketing/ak_lottery_live_v1.sql
+-- 说明:管理抽奖活动、奖品、主播及直播间,按商家隔离。
+-- =====================================================================================
+
+-- 1. 抽奖活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_lotteries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 活动名称
+ type INTEGER DEFAULT 1, -- 活动类型: 1积分抽奖, 2订单评价, 3订单支付
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ end_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ is_open BOOLEAN DEFAULT true, -- 是否开启
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 抽奖奖品表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_lottery_prizes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ lottery_id UUID NOT NULL REFERENCES public.ak_marketing_lotteries(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 奖品名称
+ prize_type TEXT NOT NULL, -- 奖品类型: points, balance, coupon, physical
+ amount DECIMAL(12,2) DEFAULT 0, -- 奖励面值/数量
+ stock INTEGER DEFAULT 0, -- 奖品库存
+ probability DECIMAL(5,2) DEFAULT 0, -- 中奖概率 (0-100)
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 直播主播表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_anchors (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ nickname TEXT NOT NULL, -- 主播昵称
+ wechat TEXT, -- 微信号
+ phone TEXT, -- 联系电话
+ avatar_url TEXT, -- 头像
+
+ status BOOLEAN DEFAULT true, -- 状态
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 4. 直播间管理表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_rooms (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ anchor_id UUID REFERENCES public.ak_marketing_live_anchors(id) ON DELETE SET NULL,
+
+ name TEXT NOT NULL, -- 直播间名称
+ background_url TEXT, -- 背景图
+ share_img_url TEXT, -- 分享图
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ end_time TIMESTAMPTZ NOT NULL, -- 计划结束时间
+
+ sort INTEGER DEFAULT 0, -- 排序
+ type TEXT DEFAULT 'phone', -- 类型: phone手机直播等
+
+ like_enabled BOOLEAN DEFAULT true, -- 开启点赞
+ sale_enabled BOOLEAN DEFAULT true, -- 开启卖货
+ comment_enabled BOOLEAN DEFAULT true, -- 开启评论
+
+ is_show BOOLEAN DEFAULT true, -- 是否显示
+ live_status INTEGER DEFAULT 1, -- 1未开始, 2直播中, 3暂停, 4已结束
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 5. 启用 RLS
+ALTER TABLE public.ak_marketing_lotteries ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_lottery_prizes ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_anchors ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_rooms ENABLE ROW LEVEL SECURITY;
+
+-- 6. 创建权限策略 (按 merchant_id 隔离)
+CREATE POLICY "Merchants manage their own lotteries" ON public.ak_marketing_lotteries FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own anchors" ON public.ak_marketing_live_anchors FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own rooms" ON public.ak_marketing_live_rooms FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看
+CREATE POLICY "Users can view lotteries" ON public.ak_marketing_lotteries FOR SELECT TO authenticated USING (is_open = true);
+CREATE POLICY "Users can view active rooms" ON public.ak_marketing_live_rooms FOR SELECT TO authenticated USING (is_show = true);
+-- =====================================================================================
+-- Schema: 打卡/签到增强配置表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_checkin_configs_v1.sql
+-- 说明:管理打卡开关、模式、提醒及基础奖励(积分/经验),按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_checkin_configs (
+ id TEXT PRIMARY KEY DEFAULT 'checkin_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_open BOOLEAN DEFAULT true, -- 签到开关
+ mode TEXT DEFAULT 'none', -- 签到模式: none(无限制), week(周循环), month(月循环)
+ notice_enabled BOOLEAN DEFAULT false, -- 签到提醒开关
+
+ integral_reward INTEGER DEFAULT 10, -- 每日签到赠送积分
+ exp_reward INTEGER DEFAULT 1, -- 每日签到赠送经验
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_checkin_configs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的打卡配置
+CREATE POLICY "Merchants manage their own checkin configs"
+ON public.ak_marketing_checkin_configs FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于前台展示)
+CREATE POLICY "Anyone can view checkin config"
+ON public.ak_marketing_checkin_configs FOR SELECT
+TO authenticated
+USING (true);
+
+-- 插入初始化数据(为每个管理员/商家初始化一条)
+-- 实际应在商家创建时触发,此处先预留
+-- =====================================================================================
+-- Schema: 新人礼配置表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_newcomer_config_v1.sql
+-- 说明:管理新用户注册后的奖励(余额、积分、优惠券),按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_newcomer_config (
+ id TEXT PRIMARY KEY DEFAULT 'newcomer_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ balance_reward DECIMAL(12,2) DEFAULT 0.00, -- 赠送余额
+ integral_reward INTEGER DEFAULT 0, -- 赠送积分
+
+ -- 赠送优惠券 (JSONB 格式): [{ "id": "coupon_uuid", "name": "显示名称", "desc": "发放描述" }]
+ coupons_json JSONB DEFAULT '[]'::jsonb,
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_newcomer_config ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的新人礼配置
+CREATE POLICY "Merchants manage their own newcomer configs"
+ON public.ak_marketing_newcomer_config FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看配置(用于移动端展示)
+CREATE POLICY "Anyone can view newcomer config"
+ON public.ak_marketing_newcomer_config FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema: 签到记录表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_signin_logs_v1.sql
+-- 说明:记录用户每日签到的详细流水,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_signin_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ points INTEGER NOT NULL DEFAULT 0, -- 本次签到获得的积分
+ is_continuous_reward BOOLEAN DEFAULT false, -- 是否包含连续签到额外奖励
+
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_signin_logs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理/查看自己的签到记录
+CREATE POLICY "Merchants manage their own signin logs"
+ON public.ak_marketing_signin_logs FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许用户查看自己的签到记录
+CREATE POLICY "Users view own signin logs"
+ON public.ak_marketing_signin_logs FOR SELECT
+TO authenticated
+USING (uid = auth.uid());
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_signin_logs_merchant ON public.ak_marketing_signin_logs(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_signin_logs_uid ON public.ak_marketing_signin_logs(uid, created_at DESC);
+-- =====================================================================================
+-- Schema: 付费会员管理相关表
+-- 位置:docs/sql/10_schema/marketing/ak_member_management_v1.sql
+-- 说明:管理会员卡类型、权益内容及基础配置,按商家隔离。
+-- =====================================================================================
+
+-- 1. 会员卡类型表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 会员名 (如: 月卡, 年卡)
+ duration_days INTEGER DEFAULT 30, -- 有效期(天),0表示永久
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 原价
+ discount_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 优惠价/实际支付价
+
+ is_open BOOLEAN DEFAULT true, -- 是否开启
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 会员权益表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_rights (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 权益名称
+ description TEXT, -- 权益简介
+ icon_url TEXT, -- 权益图标
+
+ is_show BOOLEAN DEFAULT true, -- 是否展示
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 会员基础配置表 (每个商家一条记录)
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_config (
+ id TEXT PRIMARY KEY DEFAULT 'member_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_enabled BOOLEAN DEFAULT true, -- 是否开启付费会员功能
+ bg_img_url TEXT, -- 会员期内背景图
+ expire_bg_img_url TEXT, -- 会员到期背景图
+ rules_description TEXT, -- 会员规则说明文本
+
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 4. 启用 RLS
+ALTER TABLE public.ak_marketing_member_types ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_rights ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_config ENABLE ROW LEVEL SECURITY;
+
+-- 5. 创建权限策略 (按 merchant_id 隔离)
+-- 商家管理自己的数据
+CREATE POLICY "Merchants manage their own member types" ON public.ak_marketing_member_types FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own member rights" ON public.ak_marketing_member_rights FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own member config" ON public.ak_marketing_member_config FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看 (移动端展示)
+CREATE POLICY "Users can view active member types" ON public.ak_marketing_member_types FOR SELECT TO authenticated USING (is_open = true);
+CREATE POLICY "Users can view active member rights" ON public.ak_marketing_member_rights FOR SELECT TO authenticated USING (is_show = true);
+CREATE POLICY "Users can view member config" ON public.ak_marketing_member_config FOR SELECT TO authenticated USING (true);
+
+-- 6. 索引
+CREATE INDEX IF NOT EXISTS idx_member_types_merchant ON public.ak_marketing_member_types(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_member_rights_merchant ON public.ak_marketing_member_rights(merchant_id);
+-- =====================================================================================
+-- Schema: 充值配置与额度模板表
+-- 位置:docs/sql/10_schema/marketing/ak_recharge_management_v1.sql
+-- 说明:管理用户充值开关、最低金额及预设额度,按商家隔离。
+-- =====================================================================================
+
+-- 1. 充值基础配置表 (每个商家一条记录)
+CREATE TABLE IF NOT EXISTS public.ak_recharge_configs (
+ id TEXT PRIMARY KEY DEFAULT 'recharge_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ balance_enabled BOOLEAN DEFAULT true, -- 余额功能是否启用
+ recharge_notice TEXT, -- 充值注意事项说明
+ mp_recharge_enabled BOOLEAN DEFAULT false, -- 小程序充值开关
+ min_recharge_amount DECIMAL(12,2) DEFAULT 0.01, -- 最低充值金额
+
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 充值额度模板表
+CREATE TABLE IF NOT EXISTS public.ak_recharge_quotas (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 售价(实际充值金额)
+ bonus_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 赠送金额
+
+ is_open BOOLEAN DEFAULT true, -- 是否可用
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_recharge_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_quotas ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+CREATE POLICY "Merchants manage their own recharge configs" ON public.ak_recharge_configs FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own recharge quotas" ON public.ak_recharge_quotas FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看
+CREATE POLICY "Users can view recharge configs" ON public.ak_recharge_configs FOR SELECT TO authenticated USING (true);
+CREATE POLICY "Users can view active recharge quotas" ON public.ak_recharge_quotas FOR SELECT TO authenticated USING (is_open = true);
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_recharge_quotas_merchant ON public.ak_recharge_quotas(merchant_id);
+-- =====================================================================================
+-- Schema: 签到规则配置表
+-- 位置:docs/sql/10_schema/marketing/ak_signin_configs_v1.sql
+-- 说明:记录每日签到积分、连续签到奖励及规则说明,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_signin_configs (
+ id TEXT PRIMARY KEY DEFAULT 'signin_config', -- 每个商家一个配置记录
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_enabled BOOLEAN DEFAULT true, -- 签到功能是否启用
+ daily_points INTEGER DEFAULT 10, -- 每日签到固定奖励积分
+
+ -- 连续签到奖励 (JSONB 格式): [{ "day": 3, "points": 20 }, { "day": 7, "points": 50 }]
+ continuous_rewards JSONB DEFAULT '[]'::jsonb,
+
+ rules_description TEXT DEFAULT '1.每日签到可获得积分奖励;\n2.连续签到满足天数可获得额外阶梯奖励;\n3.签到中断将重新从第一天开始计算。',
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id),
+
+ -- 约束:同一个商家只有一个签到配置记录
+ UNIQUE(merchant_id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_signin_configs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的签到配置
+CREATE POLICY "Merchants can manage their own signin configs"
+ON public.ak_signin_configs
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看配置(用于移动端签到展示)
+CREATE POLICY "Authenticated users can view signin configs"
+ON public.ak_signin_configs
+FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema Update: ml_orders 字段补齐
+-- 位置:docs/sql/10_schema/order/
+-- 对象类型:Schema (ALTER TABLE)
+-- 版本:v1
+-- 说明:为订单主表补齐核销记录 RPC 所依赖的业务字段(order_type, verified_at, verifier_id)
+-- =====================================================================================
+
+DO $$
+BEGIN
+ -- 1. 补齐 order_type (1:普通, 2:收银, 3:核销)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'order_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN order_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.order_type IS '订单类型: 1:普通, 2:收银, 3:核销';
+ END IF;
+
+ -- 2. 补齐 verified_at (核销时间)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'verified_at') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN verified_at TIMESTAMP WITH TIME ZONE;
+ COMMENT ON COLUMN public.ml_orders.verified_at IS '核销时间';
+ END IF;
+
+ -- 3. 补齐 verifier_id (核销员ID)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'verifier_id') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN verifier_id UUID REFERENCES public.ak_users(id);
+ COMMENT ON COLUMN public.ml_orders.verifier_id IS '核销员ID';
+ END IF;
+
+ -- 4. 补齐 pay_type (支付方式: 1:余额, 2:微信, 3:支付宝, 4:线下支付)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'pay_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN pay_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.pay_type IS '支付方式: 1:余额, 2:微信, 3:支付宝, 4:线下支付';
+ END IF;
+
+ -- 5. 补齐 channel_type (订单渠道: 1:公众号, 2:小程序, 3:H5, 4:PC, 5:APP)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'channel_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN channel_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.channel_type IS '订单渠道: 1:公众号, 2:小程序, 3:H5, 4:PC, 5:APP';
+ END IF;
+
+END $$;
+-- 1. 商品标签分组表
+CREATE TABLE IF NOT EXISTS public.ak_product_label_groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 商品标签表
+CREATE TABLE IF NOT EXISTS public.ak_product_labels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ group_id UUID REFERENCES public.ak_product_label_groups(id) ON DELETE SET NULL,
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ is_active BOOLEAN DEFAULT true,
+ show_in_mobile BOOLEAN DEFAULT true,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_product_label_groups ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_product_labels ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 分组策略
+CREATE POLICY "Users can manage their own label groups"
+ON public.ak_product_label_groups FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 标签策略
+CREATE POLICY "Users can manage their own labels"
+ON public.ak_product_labels FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_label_groups_merchant ON public.ak_product_label_groups(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_labels_group ON public.ak_product_labels(group_id);
+CREATE INDEX IF NOT EXISTS idx_labels_merchant ON public.ak_product_labels(merchant_id);
+-- =====================================================================================
+-- Schema: 商品会员价表 (按 SKU + 等级 维度)
+-- 位置:docs/sql/10_schema/product/ak_product_member_prices_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录特定商品 SKU 在不同会员等级下的专享价格,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_product_member_prices (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_id UUID NOT NULL REFERENCES public.ml_product_skus(id) ON DELETE CASCADE,
+ level_id UUID NOT NULL REFERENCES public.ak_user_levels(id) ON DELETE CASCADE,
+
+ member_price DECIMAL(12,2) NOT NULL CHECK (member_price >= 0),
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ -- 约束:同一个商家的同一个 SKU 在同一个等级下只能有一个会员价
+ UNIQUE(merchant_id, sku_id, level_id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_product ON public.ak_product_member_prices(product_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_sku ON public.ak_product_member_prices(sku_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_level ON public.ak_product_member_prices(level_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_merchant ON public.ak_product_member_prices(merchant_id);
+
+-- 启用 RLS
+ALTER TABLE public.ak_product_member_prices ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的商品会员价
+CREATE POLICY "Merchants can manage their own product member prices"
+ON public.ak_product_member_prices
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看会员价(前台下单需计算)
+CREATE POLICY "Authenticated users can view product member prices"
+ON public.ak_product_member_prices
+FOR SELECT
+TO authenticated
+USING (true);
+-- 商品保障/服务条款(按 merchant_id 隔离)
+CREATE TABLE IF NOT EXISTS public.ak_product_protections (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ icon_url TEXT,
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_product_protections_merchant ON public.ak_product_protections(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_ak_product_protections_active ON public.ak_product_protections(is_active);
+
+ALTER TABLE public.ak_product_protections ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own product protections"
+ON public.ak_product_protections
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+-- 商品规格模板表 + 商品参数模板表(按 merchant_id 隔离)
+
+-- 1) 商品规格模板表
+CREATE TABLE IF NOT EXISTS public.ak_product_spec_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ specs TEXT NOT NULL DEFAULT '',
+ attrs TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_spec_templates_merchant ON public.ak_product_spec_templates(merchant_id);
+
+ALTER TABLE public.ak_product_spec_templates ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own spec templates"
+ON public.ak_product_spec_templates
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+
+-- 2) 商品参数模板表
+CREATE TABLE IF NOT EXISTS public.ak_product_param_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ params JSONB NOT NULL DEFAULT '[]'::jsonb,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_param_templates_merchant ON public.ak_product_param_templates(merchant_id);
+
+ALTER TABLE public.ak_product_param_templates ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own param templates"
+ON public.ak_product_param_templates
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+-- =====================================================================================
+-- Schema: 运费模板表
+-- 位置:docs/sql/10_schema/product/ak_shipping_templates_v1.sql
+-- 说明:管理商家的运费计算规则,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_shipping_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 模板名称
+ calc_method TEXT DEFAULT 'piece', -- 计费方式: piece(件数), weight(重量), volume(体积)
+ is_free_shipping BOOLEAN DEFAULT false, -- 是否包邮
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_shipping_templates ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家管理自己的模板
+CREATE POLICY "Merchants manage own shipping templates"
+ON public.ak_shipping_templates FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看
+CREATE POLICY "Authenticated users view shipping templates"
+ON public.ak_shipping_templates FOR SELECT
+TO authenticated
+USING (true);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_shipping_templates_merchant ON public.ak_shipping_templates(merchant_id);
+-- Schema: 医疗商城独立分类与商品分类关联
+-- 执行阶段: 10_schema
+
+CREATE TABLE IF NOT EXISTS public.medical_mall_categories (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ parent_id TEXT NULL REFERENCES public.medical_mall_categories(id),
+ level INTEGER NOT NULL CHECK (level IN (1, 2)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ icon TEXT,
+ image_url TEXT,
+ description TEXT,
+ scene TEXT NOT NULL DEFAULT 'medical_mall',
+ category_type TEXT NOT NULL DEFAULT 'normal',
+ compliance_type TEXT NOT NULL DEFAULT 'normal',
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ deleted_at TIMESTAMPTZ NULL,
+ deleted_by UUID NULL REFERENCES public.ak_users(id),
+ restored_at TIMESTAMPTZ NULL,
+ restored_by UUID NULL REFERENCES public.ak_users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.medical_mall_categories IS '医疗商城独立分类表';
+COMMENT ON COLUMN public.medical_mall_categories.scene IS '分类场景,如 medical_mall / home_mall';
+COMMENT ON COLUMN public.medical_mall_categories.category_type IS '分类类型:device / otc / care / rehab / elderly / nutrition / protection / tcm / all';
+COMMENT ON COLUMN public.medical_mall_categories.compliance_type IS '合规类型:normal / otc / device_class_i / device_class_ii / device_class_iii / rx_hidden';
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_parent
+ ON public.medical_mall_categories(parent_id)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_level_sort
+ ON public.medical_mall_categories(level, sort_order)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_scene_active
+ ON public.medical_mall_categories(scene, is_active)
+ WHERE deleted_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS public.medical_mall_product_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ category_id TEXT NOT NULL REFERENCES public.medical_mall_categories(id),
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ NULL,
+ deleted_by UUID NULL REFERENCES public.ak_users(id),
+ restored_at TIMESTAMPTZ NULL,
+ restored_by UUID NULL REFERENCES public.ak_users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (product_id, category_id)
+);
+
+COMMENT ON TABLE public.medical_mall_product_categories IS '医疗商城商品与分类关联表';
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_product_categories_category
+ ON public.medical_mall_product_categories(category_id, sort_order)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_product_categories_product
+ ON public.medical_mall_product_categories(product_id)
+ WHERE deleted_at IS NULL;-- =====================================================================================
+-- Schema Update: ml_products 扩展字段 (物流、营销、高级设置)
+-- 位置:docs/sql/10_schema/product/ml_products_ext_v1.sql
+-- 说明:补齐商品编辑页 Step 3-6 所需的持久化字段。
+-- =====================================================================================
+
+DO $$
+BEGIN
+ -- 1. 物流设置:关联运费模板
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'shipping_template_id') THEN
+ ALTER TABLE public.ml_products ADD COLUMN shipping_template_id UUID REFERENCES public.ak_shipping_templates(id) ON DELETE SET NULL;
+ COMMENT ON COLUMN public.ml_products.shipping_template_id IS '关联运费模板ID';
+ END IF;
+
+ -- 2. 营销设置:赠送积分
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'give_integral') THEN
+ ALTER TABLE public.ml_products ADD COLUMN give_integral INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.give_integral IS '购买赠送积分';
+ END IF;
+
+ -- 3. 高级设置:警戒库存
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'stock_warning') THEN
+ ALTER TABLE public.ml_products ADD COLUMN stock_warning INTEGER DEFAULT 10;
+ COMMENT ON COLUMN public.ml_products.stock_warning IS '库存报警数值';
+ END IF;
+
+ -- 4. 高级设置:虚拟销量
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'virtual_sales') THEN
+ ALTER TABLE public.ml_products ADD COLUMN virtual_sales INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.virtual_sales IS '虚拟销量(展示用)';
+ END IF;
+
+ -- 5. 高级设置:排序
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'sort_order') THEN
+ ALTER TABLE public.ml_products ADD COLUMN sort_order INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.sort_order IS '商品排序权重';
+ END IF;
+
+END $$;
+-- =====================================================================================
+-- Schema: 权限管理 (RBAC) 核心表
+-- 位置:docs/sql/10_schema/user/ak_auth_system_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:包含角色表、权限/菜单表及用户角色关联表
+-- =====================================================================================
+
+-- 1. 角色表
+CREATE TABLE IF NOT EXISTS public.ak_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE, -- 角色名称 (如: 超级管理员)
+ code TEXT NOT NULL UNIQUE, -- 角色编码 (如: super_admin)
+ description TEXT, -- 角色描述
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 权限/菜单表
+CREATE TABLE IF NOT EXISTS public.ak_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ parent_id UUID REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 权限/菜单名称
+ code TEXT NOT NULL UNIQUE, -- 权限编码 (如: order_view)
+ type TEXT NOT NULL, -- 类型: menu(菜单), button(按钮/接口)
+
+ path TEXT, -- 前端路由路径 (仅针对 menu)
+ icon TEXT, -- 图标
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ is_visible BOOLEAN DEFAULT TRUE, -- 菜单是否在左侧可见
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 3. 用户-角色关联表
+-- 映射管理员 (ak_users) 与角色
+CREATE TABLE IF NOT EXISTS public.ak_admin_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
+
+ assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ assigned_by UUID REFERENCES public.ak_users(id),
+
+ UNIQUE(user_id, role_id)
+);
+
+-- 4. 角色-权限关联表
+CREATE TABLE IF NOT EXISTS public.ak_role_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
+ permission_id UUID NOT NULL REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
+
+ UNIQUE(role_id, permission_id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_permissions_parent_id ON public.ak_permissions(parent_id);
+CREATE INDEX IF NOT EXISTS idx_admin_roles_user_id ON public.ak_admin_roles(user_id);
+CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON public.ak_role_permissions(role_id);
+
+-- 注释
+COMMENT ON TABLE public.ak_roles IS '后台管理角色表';
+COMMENT ON TABLE public.ak_permissions IS '功能权限与菜单定义表';
+COMMENT ON TABLE public.ak_admin_roles IS '管理员角色分配表';
+COMMENT ON TABLE public.ak_role_permissions IS '角色权限映射表';
+-- =====================================================================================
+-- Schema: 用户分组表
+-- 位置:docs/sql/10_schema/user/ak_user_groups_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户分组定义,支持逻辑删除和状态管理
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ remark TEXT NULL,
+ status INT NOT NULL DEFAULT 1, -- 1:启用, 0:禁用
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_groups_name_length CHECK (char_length(name) >= 1)
+);
+
+-- 唯一性约束(仅对未删除记录生效)
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_groups_name_uniq_active
+ ON public.ak_user_groups (name)
+ WHERE deleted_at IS NULL;
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ak_user_groups_status_idx ON public.ak_user_groups (status) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS ak_user_groups_created_at_idx ON public.ak_user_groups (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户标签表
+-- 位置:docs/sql/10_schema/user/ak_user_labels_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户标签定义,支持逻辑删除与状态管理
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_labels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ color TEXT NULL,
+ remark TEXT NULL,
+ status INT NOT NULL DEFAULT 1,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_labels_name_length CHECK (char_length(name) >= 1)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_labels_name_uniq_active
+ ON public.ak_user_labels (name)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_labels_status_idx
+ ON public.ak_user_labels (status)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_labels_created_at_idx
+ ON public.ak_user_labels (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户等级表
+-- 位置:docs/sql/10_schema/user/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户等级(经验值/折扣/展示/状态),支持逻辑删除
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_levels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ level_weight INT NOT NULL,
+ min_experience INT NOT NULL DEFAULT 0,
+ discount_percent INT NOT NULL DEFAULT 100,
+
+ is_visible BOOLEAN NOT NULL DEFAULT TRUE,
+ status INT NOT NULL DEFAULT 1,
+
+ icon_url TEXT NULL,
+ bg_image_url TEXT NULL,
+ bg_style_json JSONB NULL,
+
+ remark TEXT NULL,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_levels_level_weight_nonnegative CHECK (level_weight >= 0),
+ CONSTRAINT ak_user_levels_min_experience_nonnegative CHECK (min_experience >= 0),
+ CONSTRAINT ak_user_levels_discount_percent_range CHECK (discount_percent BETWEEN 1 AND 100)
+);
+
+-- 唯一性(仅对未删除记录生效)
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_levels_name_uniq_active
+ ON public.ak_user_levels (name)
+ WHERE deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_levels_level_weight_uniq_active
+ ON public.ak_user_levels (level_weight)
+ WHERE deleted_at IS NULL;
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ak_user_levels_active_filter_idx
+ ON public.ak_user_levels (status, is_visible)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_levels_min_experience_idx
+ ON public.ak_user_levels (min_experience)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_levels_level_weight_desc_idx
+ ON public.ak_user_levels (level_weight DESC)
+ WHERE deleted_at IS NULL;
+-- Schema Update: public.ak_users 增加真实姓名与手机号字段(用于分销/推广员等管理端展示)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'ak_users' AND column_name = 'real_name'
+ ) THEN
+ ALTER TABLE public.ak_users ADD COLUMN real_name text;
+ COMMENT ON COLUMN public.ak_users.real_name IS '真实姓名(可选)';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'ak_users' AND column_name = 'phone'
+ ) THEN
+ ALTER TABLE public.ak_users ADD COLUMN phone text;
+ COMMENT ON COLUMN public.ak_users.phone IS '手机号(可选)';
+ END IF;
+END $$;
+-- =====================================================================================
+-- Schema: public.ak_users
+-- Version: v1
+-- Purpose: 修复 auth.users -> ak_users 自动同步的写入协调问题
+-- Change: 放宽 username 和 email 的 NOT NULL 约束,以允许数据库触发器成功插入新用户记录。
+-- 同时,将 role 的默认值更新为 'customer' 以符合业务逻辑。
+-- =====================================================================================
+
+BEGIN;
+
+-- 步骤 1 & 2: 允许 username/email 为空,并更新 role 默认值
+-- 这样数据库的自动用户同步触发器就不会因为缺少 NOT NULL 的值而失败。
+-- 前端代码 (ensureUserProfile) 会在用户首次登录时尝试填充这些值。
+ALTER TABLE public.ak_users
+ ALTER COLUMN username DROP NOT NULL,
+ ALTER COLUMN email DROP NOT NULL,
+ ALTER COLUMN role SET DEFAULT 'customer';
+
+COMMIT;
+-- =====================================================================================
+-- User 模块扩展 - 财务字段补全
+-- 位置:docs/sql/10_schema/user/
+-- 版本:v1
+-- 描述:为 ak_users 增加余额与佣金字段,支持财务业务。
+-- =====================================================================================
+
+ALTER TABLE public.ak_users
+ADD COLUMN IF NOT EXISTS now_money DECIMAL(12,2) DEFAULT 0,
+ADD COLUMN IF NOT EXISTS brokerage_price DECIMAL(12,2) DEFAULT 0;
+
+COMMENT ON COLUMN public.ak_users.now_money IS '用户当前余额';
+COMMENT ON COLUMN public.ak_users.brokerage_price IS '用户当前佣金';
diff --git a/docs/sql/fix_garbled.py b/docs/sql/fix_garbled.py
new file mode 100644
index 00000000..0e22198d
--- /dev/null
+++ b/docs/sql/fix_garbled.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+修复 complete_mall_database.sql 中的 UTF-8 替换字符乱码
+原始文件中大量中文字符被替换为 \xef\xbf\xbd (U+FFFD)
+"""
+
+import os
+
+import os
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+SRC = os.path.join(ROOT, "医疗-consumer", "mall_sql", "schemas", "complete_mall_database.sql")
+DST = os.path.join(ROOT, "医疗-consumer", "mall_sql", "schemas", "complete_mall_database_fixed.sql")
+DOC = os.path.join(ROOT, "医疗-consumer", "docs", "数据库对接文档.md")
+
+# 读取原始文件(bytes)
+with open(SRC, "rb") as f:
+ raw = f.read()
+
+# 先把替换字符替换成一个临时标记,便于后续处理
+marker = b"{{GARBLED}}"
+raw = raw.replace(b"\xef\xbf\xbd", marker)
+
+# 解码为字符串(此时已无替换字符)
+text = raw.decode("utf-8")
+
+# 批量替换:根据上下文推断正确字符
+replacements = [
+ # 模块标题 / 注释
+ ("数据库设{{GARBLED}}", "数据库设计"),
+ ("复用{{GARBLED}} ak_users", "复用主表 ak_users"),
+ ("包含: 表结构、索引、触发器、RLS策略、视图、函{{GARBLED}}", "包含: 表结构、索引、触发器、RLS策略、视图、函数"),
+ ("基础配置和扩{{GARBLED}}", "基础配置和扩展"),
+ ("启用必要的扩{{GARBLED}}", "启用必要的扩展"),
+ ("用户扩展{{GARBLED}}", "用户扩展表"),
+ ("商城用户扩展信息{{GARBLED}}", "商城用户扩展信息表"),
+ ("营业执照{{GARBLED}}", "营业执照号"),
+ ("认证状{{GARBLED}}", "认证状态"),
+ ("未认{{GARBLED}}", "未认证"),
+ ("已认{{GARBLED}}", "已认证"),
+ ("用户地址{{GARBLED}}", "用户地址表"),
+ ("商品管理{{GARBLED}}", "商品管理模块"),
+ ("商品分类{{GARBLED}}", "商品分类表"),
+ ("品牌{{GARBLED}}", "品牌表"),
+ ("商品{{GARBLED}}", "商品表"),
+ ("基础属{{GARBLED}}", "基础属性"),
+ ("状{{GARBLED}}", "状态"),
+ ("商品SKU{{GARBLED}}", "商品SKU表"),
+ ("商品规格{{GARBLED}}", "商品规格表"),
+ ("规格值数{{GARBLED}}", "规格值数组"),
+ ("店铺管理{{GARBLED}}", "店铺管理模块"),
+ ("店铺信息{{GARBLED}}", "店铺信息表"),
+ ("订单管理{{GARBLED}}", "订单管理模块"),
+ ("订单{{GARBLED}}", "订单表"),
+ ("总金{{GARBLED}}", "总金额"),
+ ("状态信{{GARBLED}}", "状态信息"),
+ ("待付{{GARBLED}}", "待付款"),
+ ("待发{{GARBLED}}", "待发货"),
+ ("待收{{GARBLED}}", "待收货"),
+ ("已完{{GARBLED}}", "已完成"),
+ ("已取{{GARBLED}}", "已取消"),
+ ("退款中 7:已退{{GARBLED}}", "退款中 7:已退款"),
+ ("订单商品{{GARBLED}}", "订单商品表"),
+ ("营销管理{{GARBLED}}", "营销管理模块"),
+ ("NULL表示平台{{GARBLED}}", "NULL表示平台券"),
+ ("满减{{GARBLED}}", "满减券"),
+ ("折扣{{GARBLED}}", "折扣券"),
+ ("百分{{GARBLED}}", "百分比"),
+ ("优惠{{GARBLED}}", "优惠值"),
+ ("最低订单金{{GARBLED}}", "最低订单金额"),
+ ("最大优惠金{{GARBLED}}", "最大优惠金额"),
+ ("总发放数{{GARBLED}}", "总发放数量"),
+ ("每用户限领数{{GARBLED}}", "每用户限领数量"),
+ ("使用次数限{{GARBLED}}", "使用次数限制"),
+ ("已结{{GARBLED}}", "已结束"),
+ ("未使{{GARBLED}}", "未使用"),
+ ("已使{{GARBLED}}", "已使用"),
+ ("已过{{GARBLED}}", "已过期"),
+ ("配送员信息{{GARBLED}}", "配送员信息表"),
+ ("电动{{GARBLED}}", "电动车"),
+ ("摩托{{GARBLED}}", "摩托车"),
+ ("配送距{{GARBLED}}", "配送距离"),
+ ("预计配送时{{GARBLED}}", "预计配送时间"),
+ ("取货{{GARBLED}}", "取货码"),
+ ("评价管理{{GARBLED}}", "评价管理模块"),
+ ("商品评价{{GARBLED}}", "商品评价表"),
+ ("已删{{GARBLED}}", "已删除"),
+ ("已隐{{GARBLED}}", "已隐藏"),
+ ("用户行为{{GARBLED}}", "用户行为模块"),
+ ("用户收藏{{GARBLED}}", "用户收藏表"),
+ ("用户浏览历史{{GARBLED}}", "用户浏览历史表"),
+ ("浏览时长({{GARBLED}}", "浏览时长(秒)"),
+ ("搜索记录{{GARBLED}}", "搜索记录表"),
+ ("系统配置{{GARBLED}}", "系统配置表"),
+ ("地区{{GARBLED}}", "地区表"),
+ ("用户扩展表索{{GARBLED}}", "用户扩展表索引"),
+ ("地址表索{{GARBLED}}", "地址表索引"),
+ ("商品表索{{GARBLED}}", "商品表索引"),
+ ("SKU表索{{GARBLED}}", "SKU表索引"),
+ ("订单表索{{GARBLED}}", "订单表索引"),
+ ("订单商品表索{{GARBLED}}", "订单商品表索引"),
+ ("收藏表索{{GARBLED}}", "收藏表索引"),
+ ("触发器函{{GARBLED}}", "触发器函数"),
+ ("自动更新 updated_at 字段的函{{GARBLED}}", "自动更新 updated_at 字段的函数"),
+ ("为需要的表创{{GARBLED}}updated_at 触发{{GARBLED}}", "为需要的表创建 updated_at 触发器"),
+ ("商品库存更新触发{{GARBLED}}", "商品库存更新触发器"),
+ ("更新商品总库{{GARBLED}}", "更新商品总库存"),
+ ("订单状态变更时的处{{GARBLED}}", "订单状态变更时的处理"),
+ ("更新商品销{{GARBLED}}", "更新商品销量"),
+ ("生成优惠券码的函{{GARBLED}}", "生成优惠券码的函数"),
+ ("计算购物车总金{{GARBLED}}", "计算购物车总金额"),
+ ("获取商品总库{{GARBLED}}", "获取商品总库存"),
+ ("自己的数{{GARBLED}}", "自己的数据"),
+ ("初始化数{{GARBLED}}", "初始化数据"),
+ ("优惠券设{{GARBLED}}", "优惠券设置"),
+ ("已创建索{{GARBLED}} 30+ 个索{{GARBLED}}", "已创建索引 30+ 个索引"),
+ ("已创建函{{GARBLED}} 10+ 个函{{GARBLED}}", "已创建函数 10+ 个函数"),
+ ("已创建视{{GARBLED}} 3 个视{{GARBLED}}", "已创建视图 3 个视图"),
+]
+
+for old, new in replacements:
+ text = text.replace(old, new)
+
+# 写入修复后的文件
+with open(DST, "w", encoding="utf-8") as f:
+ f.write(text)
+
+# 检查是否还有残留
+remaining = text.count("{{GARBLED}}")
+print(f"修复完成,残留乱码标记: {remaining}")
+if remaining > 0:
+ for i, line in enumerate(text.splitlines(), 1):
+ if "{{GARBLED}}" in line:
+ print(f" Line {i}: {line.strip()[:120]}")
diff --git a/docs/sql/fix_garbled_v2.py b/docs/sql/fix_garbled_v2.py
new file mode 100644
index 00000000..a183ba32
--- /dev/null
+++ b/docs/sql/fix_garbled_v2.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+修复 complete_mall_database.sql 中的 UTF-8 替换字符乱码 v2
+策略:先把 {{GARBLED}}? 统一为 {{GARBLED}},再批量替换
+"""
+
+import os
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+SRC = os.path.join(ROOT, "医疗-consumer", "mall_sql", "schemas", "complete_mall_database.sql")
+DST = os.path.join(ROOT, "医疗-consumer", "mall_sql", "schemas", "complete_mall_database_fixed.sql")
+
+with open(SRC, "rb") as f:
+ raw = f.read()
+
+marker = b"{{GARBLED}}"
+raw = raw.replace(b"\xef\xbf\xbd", marker)
+
+text = raw.decode("utf-8")
+
+# 第一步:把 {{GARBLED}}? 统一为 {{GARBLED}}
+text = text.replace("{{GARBLED}}?", "{{GARBLED}}")
+
+# 第二步:批量替换(按从长到短的顺序,避免子串误替换)
+replacements = [
+ # 长词优先
+ ("为需要的表创{{GARBLED}}updated_at 触发{{GARBLED}}", "为需要的表创建 updated_at 触发器"),
+ ("已创建索{{GARBLED}} 30+ 个索{{GARBLED}}", "已创建索引 30+ 个索引"),
+ ("已创建函{{GARBLED}} 10+ 个函{{GARBLED}}", "已创建函数 10+ 个函数"),
+ ("已创建视{{GARBLED}} 3 个视{{GARBLED}}", "已创建视图 3 个视图"),
+ ("包含: 表结构、索引、触发器、RLS策略、视图、函{{GARBLED}}", "包含: 表结构、索引、触发器、RLS策略、视图、函数"),
+
+ # COMMENT / 表名
+ ("商城用户扩展信息{{GARBLED}}", "商城用户扩展信息表"),
+ ("用户地址{{GARBLED}}", "用户地址表"),
+ ("商品分类{{GARBLED}}", "商品分类表"),
+ ("品牌{{GARBLED}}", "品牌表"),
+ ("商品{{GARBLED}}", "商品表"),
+ ("商品SKU{{GARBLED}}", "商品SKU表"),
+ ("商品规格{{GARBLED}}", "商品规格表"),
+ ("店铺信息{{GARBLED}}", "店铺信息表"),
+ ("订单{{GARBLED}}", "订单表"),
+ ("订单商品{{GARBLED}}", "订单商品表"),
+ ("配送员信息{{GARBLED}}", "配送员信息表"),
+ ("商品评价{{GARBLED}}", "商品评价表"),
+ ("用户收藏{{GARBLED}}", "用户收藏表"),
+ ("用户浏览历史{{GARBLED}}", "用户浏览历史表"),
+ ("搜索记录{{GARBLED}}", "搜索记录表"),
+ ("系统配置{{GARBLED}}", "系统配置表"),
+ ("地区{{GARBLED}}", "地区表"),
+
+ # 模块/标题
+ ("数据库设{{GARBLED}}", "数据库设计"),
+ ("复用{{GARBLED}} ak_users", "复用主表 ak_users"),
+ ("用户扩展{{GARBLED}}", "用户扩展表"),
+ ("商品管理{{GARBLED}}", "商品管理模块"),
+ ("店铺管理{{GARBLED}}", "店铺管理模块"),
+ ("订单管理{{GARBLED}}", "订单管理模块"),
+ ("营销管理{{GARBLED}}", "营销管理模块"),
+ ("评价管理{{GARBLED}}", "评价管理模块"),
+ ("用户行为{{GARBLED}}", "用户行为模块"),
+
+ # 状态/属性
+ ("基础属{{GARBLED}}", "基础属性"),
+ ("状{{GARBLED}}", "状态"),
+ ("认证状{{GARBLED}}", "认证状态"),
+ ("状态信{{GARBLED}}", "状态信息"),
+ ("未认{{GARBLED}}", "未认证"),
+ ("已认{{GARBLED}}", "已认证"),
+
+ # 订单状态
+ ("待付{{GARBLED}}", "待付款"),
+ ("待发{{GARBLED}}", "待发货"),
+ ("待收{{GARBLED}}", "待收货"),
+ ("已完{{GARBLED}}", "已完成"),
+ ("已取{{GARBLED}}", "已取消"),
+ ("已退{{GARBLED}}", "已退款"),
+ ("未付{{GARBLED}}", "未付款"),
+ ("已付{{GARBLED}}", "已付款"),
+ ("部分退{{GARBLED}}", "部分退款"),
+ ("全额退{{GARBLED}}", "全额退款"),
+ ("未发{{GARBLED}}", "未发货"),
+ ("已发{{GARBLED}}", "已发货"),
+ ("运输{{GARBLED}}", "运输中"),
+
+ # 配送状态
+ ("待接{{GARBLED}}", "待接单"),
+ ("已接{{GARBLED}}", "已接单"),
+ ("取货{{GARBLED}}", "取货中"),
+ ("配送失{{GARBLED}}", "配送失败"),
+
+ # 其他业务词
+ ("总金{{GARBLED}}", "总金额"),
+ ("优惠{{GARBLED}}", "优惠值"),
+ ("最低订单金{{GARBLED}}", "最低订单金额"),
+ ("最大优惠金{{GARBLED}}", "最大优惠金额"),
+ ("总发放数{{GARBLED}}", "总发放数量"),
+ ("每用户限领数{{GARBLED}}", "每用户限领数量"),
+ ("使用次数限{{GARBLED}}", "使用次数限制"),
+ ("已结{{GARBLED}}", "已结束"),
+ ("未使{{GARBLED}}", "未使用"),
+ ("已使{{GARBLED}}", "已使用"),
+ ("已过{{GARBLED}}", "已过期"),
+ ("已删{{GARBLED}}", "已删除"),
+ ("已隐{{GARBLED}}", "已隐藏"),
+
+ # 配送/商品
+ ("电动{{GARBLED}}", "电动车"),
+ ("摩托{{GARBLED}}", "摩托车"),
+ ("配送距{{GARBLED}}", "配送距离"),
+ ("预计配送时{{GARBLED}}", "预计配送时间"),
+ ("取货{{GARBLED}}", "取货码"),
+ ("浏览时长({{GARBLED}}", "浏览时长(秒)"),
+
+ # 通用词
+ ("营业执照{{GARBLED}}", "营业执照号"),
+ ("规格值数{{GARBLED}}", "规格值数组"),
+ ("NULL表示平台{{GARBLED}}", "NULL表示平台券"),
+ ("满减{{GARBLED}}", "满减券"),
+ ("折扣{{GARBLED}}", "折扣券"),
+ ("百分{{GARBLED}}", "百分比"),
+ ("自己的数{{GARBLED}}", "自己的数据"),
+ ("初始化数{{GARBLED}}", "初始化数据"),
+ ("优惠券设{{GARBLED}}", "优惠券设置"),
+
+ # 索引/触发器/函数/视图
+ ("用户扩展表索{{GARBLED}}", "用户扩展表索引"),
+ ("地址表索{{GARBLED}}", "地址表索引"),
+ ("商品表索{{GARBLED}}", "商品表索引"),
+ ("SKU表索{{GARBLED}}", "SKU表索引"),
+ ("订单表索{{GARBLED}}", "订单表索引"),
+ ("订单商品表索{{GARBLED}}", "订单商品表索引"),
+ ("收藏表索{{GARBLED}}", "收藏表索引"),
+ ("触发器函{{GARBLED}}", "触发器函数"),
+ ("自动更新 updated_at 字段的函{{GARBLED}}", "自动更新 updated_at 字段的函数"),
+ ("商品库存更新触发{{GARBLED}}", "商品库存更新触发器"),
+ ("更新商品总库{{GARBLED}}", "更新商品总库存"),
+ ("订单状态变更时的处{{GARBLED}}", "订单状态变更时的处理"),
+ ("更新商品销{{GARBLED}}", "更新商品销量"),
+ ("生成优惠券码的函{{GARBLED}}", "生成优惠券码的函数"),
+ ("计算购物车总金{{GARBLED}}", "计算购物车总金额"),
+ ("获取商品总库{{GARBLED}}", "获取商品总库存"),
+
+ # 扩/函单独处理(避免被前面的长词覆盖后残留)
+ ("基础配置和扩{{GARBLED}}", "基础配置和扩展"),
+ ("启用必要的扩{{GARBLED}}", "启用必要的扩展"),
+]
+
+# 按长度降序排序,避免短词先替换导致长词无法匹配
+replacements.sort(key=lambda x: len(x[0]), reverse=True)
+
+for old, new in replacements:
+ text = text.replace(old, new)
+
+# 第三步:清理残留的 ?(在中文后面多余的 ?)
+# 对于文件末尾 RAISE NOTICE 等行中可能残留的 {{GARBLED}} 做兜底
+if "{{GARBLED}}" in text:
+ for i, line in enumerate(text.splitlines(), 1):
+ if "{{GARBLED}}" in line:
+ print(f" 残留 Line {i}: {line.strip()[:120]}")
+
+with open(DST, "w", encoding="utf-8") as f:
+ f.write(text)
+
+remaining = text.count("{{GARBLED}}")
+print(f"修复完成,残留乱码标记: {remaining}")
diff --git a/docs/sql/generate_db_doc.py b/docs/sql/generate_db_doc.py
new file mode 100644
index 00000000..c780248c
--- /dev/null
+++ b/docs/sql/generate_db_doc.py
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+解析医疗-consumer / 医疗-delivery 的 Schema、RLS、RPC,生成数据库对接文档
+"""
+
+import re
+import os
+from collections import defaultdict
+
+OUTPUT_PATH = r"D:\骅锋\医疗-consumer\docs\数据库对接文档.md"
+SCHEMA_PATH = "all_schema_merged.sql"
+RLS_PATH = "all_rls_merged.sql"
+RPC_PATH = "all_rpc_merged.sql"
+
+# 定义各端涉及的表(用于标记所属端)
+CONSUMER_TABLES = {
+ "ak_users", "ml_user_profiles", "ml_user_addresses", "ml_shopping_cart",
+ "ml_products", "ml_product_skus", "ml_product_specs", "ml_product_reviews",
+ "ml_product_categories", "medical_mall_categories", "ml_product_labels",
+ "ml_product_member_prices", "ml_product_protections", "ml_product_templates",
+ "ak_shipping_templates", "ml_products_ext",
+ "ml_orders", "ml_order_items",
+ "ml_delivery_tasks", "ml_delivery_staff", "ml_delivery_stations",
+ "ml_system_configs",
+ "ml_cms_content", "ml_cms_categories", "ml_cms_banners",
+ "ml_user_bill", "ml_user_recharge", "ml_extract", "ml_invoices",
+ "ml_user_coupons", "ml_coupon_templates", "ml_seckill", "ml_group_buy",
+ "ml_bargain", "ml_live", "ml_lottery", "ml_signin_logs", "ml_checkin_configs",
+ "ml_member_cards", "ml_recharge_rules",
+ "ak_user_groups", "ak_user_labels", "ak_user_levels",
+ "ak_distribution_agents", "ak_distribution_divisions", "ak_distribution_config",
+ "ak_distribution_level", "ak_promoter_relations", "ak_commission_logs",
+ "ak_distribution_agent_applications", "ak_distribution_division_applications",
+ "ak_diy_pages",
+ "ml_kefu_accounts", "ml_kefu_sessions", "ml_kefu_messages",
+ "homecare_patients", "homecare_nurses", "homecare_services", "homecare_orders",
+ "homecare_schedules", "homecare_evaluations",
+}
+
+DELIVERY_TABLES = {
+ "ak_users",
+ "ml_delivery_tasks", "ml_delivery_staff", "ml_delivery_stations",
+ "ml_orders", "ml_order_items",
+ "homecare_patients", "homecare_nurses", "homecare_services", "homecare_orders",
+ "homecare_schedules", "homecare_evaluations",
+ "ml_system_configs",
+}
+
+def parse_schema(path):
+ """提取 CREATE TABLE 语句"""
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ tables = []
+ # 匹配 CREATE TABLE [IF NOT EXISTS] name (...) 或 CREATE TABLE public.name (...)
+ pattern = re.compile(
+ r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)\s*\((.*?)\);",
+ re.DOTALL | re.IGNORECASE,
+ )
+
+ for match in pattern.finditer(content):
+ table_name = match.group(1)
+ body = match.group(2)
+ fields = []
+ constraints = []
+
+ # 简单按行拆分(注意括号内可能有嵌套,但这里足够用)
+ lines = [line.strip() for line in body.split("\n") if line.strip()]
+ for line in lines:
+ line = line.rstrip(",")
+ # 跳过注释行
+ if line.startswith("--") or line.startswith("/*"):
+ continue
+ # 约束行(PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, CONSTRAINT)
+ if re.match(r"^(CONSTRAINT|PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK)", line, re.I):
+ constraints.append(line)
+ continue
+ # 字段定义行:name type [constraints]
+ m = re.match(r"^(\w+)\s+([\w\[\]]+(?:\s*\([^)]+\))?)\s*(.*)$", line)
+ if m:
+ col_name = m.group(1)
+ col_type = m.group(2).strip()
+ col_rest = m.group(3).strip()
+ fields.append({
+ "name": col_name,
+ "type": col_type,
+ "rest": col_rest,
+ })
+
+ tables.append({
+ "name": table_name,
+ "fields": fields,
+ "constraints": constraints,
+ "raw": match.group(0),
+ })
+
+ return tables
+
+
+def parse_rls(path):
+ """提取每个表的 RLS 策略和启用状态"""
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ table_rls = defaultdict(lambda: {"enabled": False, "policies": []})
+
+ # ALTER TABLE ... ENABLE ROW LEVEL SECURITY
+ for m in re.finditer(
+ r"ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY",
+ content, re.I,
+ ):
+ table_rls[m.group(1)]["enabled"] = True
+
+ # CREATE POLICY ... ON table_name
+ for m in re.finditer(
+ r"CREATE\s+POLICY\s+(\w+)\s+ON\s+(?:public\.)?(\w+)\s+.*?((?:FOR\s+\w+\s+)?(?:TO\s+[\w,\s]+\s+)?(?:USING\s*\([^)]*\)\s*)?(?:WITH\s+CHECK\s*\([^)]*\)\s*)?);",
+ content, re.DOTALL | re.IGNORECASE,
+ ):
+ policy_name = m.group(1)
+ table_name = m.group(2)
+ policy_sql = m.group(0)
+ table_rls[table_name]["policies"].append({
+ "name": policy_name,
+ "sql": policy_sql,
+ })
+
+ return table_rls
+
+
+def parse_rpc(path):
+ """提取 RPC 函数名、参数、返回类型"""
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ funcs = []
+ pattern = re.compile(
+ r"CREATE\s+OR\s+REPLACE\s+FUNCTION\s+(?:public\.)?(\w+)\s*\((.*?)\)\s*RETURNS\s+(.*?)(?:LANGUAGE\s+\w+|AS\s*\$\$|SECURITY\s+DEFINER)",
+ re.DOTALL | re.IGNORECASE,
+ )
+ for m in pattern.finditer(content):
+ funcs.append({
+ "name": m.group(1),
+ "args": m.group(2).strip(),
+ "returns": m.group(3).strip(),
+ })
+
+ return funcs
+
+
+def get_table_belong(table_name):
+ belongs = []
+ if table_name in CONSUMER_TABLES:
+ belongs.append("consumer")
+ if table_name in DELIVERY_TABLES:
+ belongs.append("delivery")
+ if not belongs:
+ return "admin / 系统"
+ return " / ".join(belongs)
+
+
+def main():
+ os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+ tables = parse_schema(SCHEMA_PATH)
+ rls_data = parse_rls(RLS_PATH)
+ rpc_funcs = parse_rpc(RPC_PATH)
+
+ # 按模块分组(简单按表名前缀)
+ module_map = defaultdict(list)
+ for t in tables:
+ name = t["name"]
+ if name.startswith("ak_users") or name.startswith("ml_user") or name.startswith("ak_user") or name in {"ak_roles", "ak_permissions", "ak_admin_roles", "ak_role_permissions"}:
+ module_map["用户与权限"].append(t)
+ elif name.startswith("ml_product") or name.startswith("medical_mall") or name.startswith("ak_shipping") or name.startswith("ml_products"):
+ module_map["商品与类目"].append(t)
+ elif name.startswith("ml_order"):
+ module_map["订单"].append(t)
+ elif name.startswith("ml_delivery"):
+ module_map["配送与物流"].append(t)
+ elif name.startswith("homecare"):
+ module_map["居家护理"].append(t)
+ elif name.startswith("ml_system"):
+ module_map["系统配置"].append(t)
+ elif name.startswith("ml_cms") or name.startswith("ak_diy"):
+ module_map["内容与装修"].append(t)
+ elif name.startswith("ml_kefu"):
+ module_map["客服"].append(t)
+ elif "finance" in name or name in {"ml_user_bill", "ml_user_recharge", "ml_extract", "ml_invoices"}:
+ module_map["财务"].append(t)
+ elif name.startswith("ml_") or name.startswith("ak_"):
+ module_map["营销与分销"].append(t)
+ else:
+ module_map["其他"].append(t)
+
+ lines = []
+ lines.append("# 医疗-consumer & 医疗-delivery 数据库对接文档")
+ lines.append("")
+ lines.append("> **生成日期**:2026-06-01")
+ lines.append("> **适用范围**:医疗-consumer(消费者端)+ 医疗-delivery(配送端)")
+ lines.append("> **数据库**:PostgreSQL (Supabase)")
+ lines.append("> **规范**:所有用户侧表已启用 RLS(行级安全),全局/管理后台查询请走 RPC(SECURITY DEFINER)")
+ lines.append("")
+
+ lines.append("## 目录")
+ lines.append("")
+ lines.append("- [通用规范说明](#通用规范说明)")
+ for mod in module_map:
+ anchor = mod.replace(" ", "-").replace("与", "")
+ lines.append(f"- [{mod}](#{anchor})")
+ lines.append("- [完整对接 SQL 汇总](#完整对接-sql-汇总)")
+ lines.append("- [关键 RPC 清单](#关键-rpc-清单)")
+ lines.append("")
+
+ lines.append("## 通用规范说明")
+ lines.append("")
+ lines.append("### 软删除标准")
+ lines.append("本项目所有业务表默认采用**软删除**,标准字段如下:")
+ lines.append("- `deleted_at` (timestamptz):删除时间,NULL 表示未删除")
+ lines.append("- `deleted_by` (uuid):删除操作人")
+ lines.append("- `created_at` (timestamptz):创建时间,默认 `now()`")
+ lines.append("- `updated_at` (timestamptz):更新时间,默认 `now()`")
+ lines.append("")
+ lines.append("> **注意**:RLS 策略默认过滤 `deleted_at IS NULL` 的行,查询时无需手动加条件,但管理后台统计需视情况处理。")
+ lines.append("")
+ lines.append("### 角色字段权威口径")
+ lines.append("- 统一用户主表:`public.ak_users`")
+ lines.append("- 角色唯一权威字段:`ak_users.role`,取值:`customer`(消费者)、`merchant`(商家)、`delivery`(配送员)、`admin`(管理员)、`analytics`(数据分析师)")
+ lines.append("- 商城用户扩展档案:`public.ml_user_profiles`,与 `ak_users` 1:1 关系(`user_id` UNIQUE)")
+ lines.append("")
+ lines.append("### 状态机速查")
+ lines.append("| 状态域 | 字段名 | 关键取值 | 说明 |")
+ lines.append("|--------|--------|----------|------|")
+ lines.append("| 订单流程 | `order_status` | 0=待付款, 1=待发货, 2=待收货, 3=待评价, 4=已完成, 5=已取消, 6=退款中, 7=已退款 | 主状态 |")
+ lines.append("| 支付状态 | `payment_status` | 0=未支付, 1=部分支付, 2=已支付, 3=部分退款, 4=全额退款 | 支付线 |")
+ lines.append("| 物流状态 | `shipping_status` | 0=未发货, 1=已发货, 2=运输中, 3=已签收, 4=已送达 | 物流线 |")
+ lines.append("| 配送任务 | `status` | 0=待分配, 1=待接单, 2=已接单, 3=取货中, 4=配送中, 5=已送达, 6=配送失败 | 配送端 |")
+ lines.append("")
+
+ # 逐模块输出
+ for mod, tbls in module_map.items():
+ anchor = mod.replace(" ", "-").replace("与", "")
+ lines.append(f"## {mod}")
+ lines.append("")
+ for t in tbls:
+ tname = t["name"]
+ belong = get_table_belong(tname)
+ lines.append(f"### {tname}")
+ lines.append(f"- **所属端**:{belong}")
+ lines.append("")
+ lines.append("| 字段名 | 数据类型 | 约束/默认值 | 说明 |")
+ lines.append("|--------|----------|-------------|------|")
+ for f in t["fields"]:
+ rest = f["rest"]
+ # 简化 rest,突出 DEFAULT 和 NOT NULL
+ note = rest
+ lines.append(f"| {f['name']} | {f['type']} | {note} | |")
+ lines.append("")
+
+ if t["constraints"]:
+ lines.append("**约束**:")
+ for c in t["constraints"]:
+ lines.append(f"- `{c}`")
+ lines.append("")
+
+ rls = rls_data.get(tname)
+ if rls and rls["policies"]:
+ lines.append("**RLS 策略**:")
+ for p in rls["policies"]:
+ lines.append(f"- `{p['name']}`")
+ lines.append("")
+ elif rls and rls["enabled"] and not rls["policies"]:
+ lines.append("**RLS**:已启用,暂无显式策略(默认拒绝直接访问)")
+ lines.append("")
+
+ # 查找关联的 RPC
+ related_rpc = [fn for fn in rpc_funcs if tname.replace("ml_", "").replace("ak_", "").replace("_", "") in fn["name"].replace("_", "")]
+ if related_rpc:
+ lines.append("**相关 RPC**:")
+ for fn in related_rpc[:5]: # 最多显示5个,避免太长
+ lines.append(f"- `{fn['name']}({fn['args']}) -> {fn['returns']}`")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ # 完整对接 SQL 汇总
+ lines.append("## 完整对接 SQL 汇总")
+ lines.append("")
+ lines.append("> 以下 SQL 按执行顺序排列:Schema → RLS → RPC。请在新项目/新环境中按顺序执行。")
+ lines.append("")
+ lines.append("### 1) Schema(建表)")
+ lines.append("```sql")
+ with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+ lines.append("### 2) RLS(行级安全)")
+ lines.append("```sql")
+ with open(RLS_PATH, "r", encoding="utf-8") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+ lines.append("### 3) RPC(关键函数,完整见 30_rpc 目录)")
+ lines.append("```sql")
+ with open(RPC_PATH, "r", encoding="utf-8") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+
+ # RPC 清单
+ lines.append("## 关键 RPC 清单")
+ lines.append("")
+ lines.append("| 函数名 | 参数 | 返回 | 用途 |")
+ lines.append("|--------|------|------|------|")
+ for fn in rpc_funcs:
+ args = fn["args"].replace("\n", " ")[:80]
+ lines.append(f"| {fn['name']} | {args} | {fn['returns']} | |")
+ lines.append("")
+
+ with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines))
+
+ print(f"文档已生成:{OUTPUT_PATH}")
+ print(f"共汇总表:{len(tables)} 张,RPC 函数:{len(rpc_funcs)} 个")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/sql/generate_db_doc_v2.py b/docs/sql/generate_db_doc_v2.py
new file mode 100644
index 00000000..d55715e5
--- /dev/null
+++ b/docs/sql/generate_db_doc_v2.py
@@ -0,0 +1,384 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+解析医疗-consumer / 医疗-delivery 的 Schema、RLS、RPC,生成数据库对接文档 v2
+整合来源:
+ - complete_mall_database.sql(核心商城表)
+ - docs/sql/10_schema/(增量模块表)
+ - pages/user/test/USER_AUTH_SCHEMA.sql(ak_users 定义)
+ - docs/sql/20_rls/(RLS 策略)
+ - docs/sql/30_rpc/(RPC 函数)
+"""
+
+import re
+import os
+from collections import defaultdict, OrderedDict
+
+OUTPUT_PATH = r"D:\骅锋\医疗-consumer\docs\数据库对接文档.md"
+
+SOURCES = [
+ ("医疗-consumer/mall_sql/schemas/complete_mall_database_fixed.sql", "core"),
+ ("医疗-consumer/docs/sql/all_schema_merged.sql", "ext"),
+ ("医疗-consumer/pages/user/test/USER_AUTH_SCHEMA.sql", "auth"),
+]
+
+RLS_PATH = "医疗-consumer/docs/sql/all_rls_merged.sql"
+RPC_PATH = "医疗-consumer/docs/sql/all_rpc_merged.sql"
+
+# 定义各端涉及的表
+CONSUMER_TABLES = {
+ "ak_users", "ml_user_profiles", "ml_user_addresses", "ml_shopping_cart",
+ "ml_products", "ml_product_skus", "ml_product_specs", "ml_product_reviews",
+ "ml_categories", "medical_mall_categories", "ml_product_labels",
+ "ml_product_member_prices", "ml_product_protections", "ml_product_templates",
+ "ak_shipping_templates", "ml_products_ext", "ml_brands", "ml_shops",
+ "ml_orders", "ml_order_items",
+ "ml_delivery_tasks", "ml_delivery_drivers", "ml_delivery_staff", "ml_delivery_stations",
+ "ml_system_configs", "ml_regions",
+ "ml_cms_content", "ml_cms_categories", "ml_cms_banners", "ml_articles", "ml_article_categories",
+ "ml_user_bill", "ml_user_recharge", "ml_extract", "ml_invoices",
+ "ml_user_coupons", "ml_coupon_templates", "ml_seckill", "ml_group_buy",
+ "ml_bargain", "ml_live", "ml_lottery", "ml_signin_logs", "ml_checkin_configs",
+ "ml_member_cards", "ml_recharge_rules",
+ "ak_user_groups", "ak_user_labels", "ak_user_levels",
+ "ak_distribution_agents", "ak_distribution_divisions", "ak_distribution_config",
+ "ak_distribution_level", "ak_promoter_relations", "ak_commission_logs",
+ "ak_distribution_agent_applications", "ak_distribution_division_applications",
+ "ak_diy_pages", "ak_roles", "ak_permissions", "ak_admin_roles", "ak_role_permissions",
+ "ml_kefu_accounts", "ml_kefu_sessions", "ml_kefu_messages",
+ "homecare_patients", "homecare_nurses", "homecare_services", "homecare_orders",
+ "homecare_schedules", "homecare_evaluations",
+ "ml_user_favorites", "ml_browse_history", "ml_search_history",
+}
+
+DELIVERY_TABLES = {
+ "ak_users",
+ "ml_delivery_tasks", "ml_delivery_drivers", "ml_delivery_staff", "ml_delivery_stations",
+ "ml_orders", "ml_order_items",
+ "homecare_patients", "homecare_nurses", "homecare_services", "homecare_orders",
+ "homecare_schedules", "homecare_evaluations",
+ "ml_system_configs",
+}
+
+
+def parse_sql_file(path, source_type):
+ """提取 CREATE TABLE 语句"""
+ if not os.path.exists(path):
+ print(f"[WARN] 文件不存在: {path}")
+ return {}
+
+ # 尝试多种编码
+ content = None
+ for enc in ["utf-8", "gbk", "gb2312", "latin-1"]:
+ try:
+ with open(path, "r", encoding=enc) as f:
+ content = f.read()
+ break
+ except UnicodeDecodeError:
+ continue
+ if content is None:
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ content = f.read()
+
+ tables = {}
+ pattern = re.compile(
+ r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)\s*\((.*?)\);",
+ re.DOTALL | re.IGNORECASE,
+ )
+
+ for match in pattern.finditer(content):
+ table_name = match.group(1)
+ body = match.group(2)
+ fields = []
+ constraints = []
+
+ lines = [line.strip() for line in body.split("\n") if line.strip()]
+ for line in lines:
+ line = line.rstrip(",")
+ if line.startswith("--") or line.startswith("/*") or line.startswith("*"):
+ continue
+ if re.match(r"^(CONSTRAINT|PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK)", line, re.I):
+ constraints.append(line)
+ continue
+ m = re.match(r"^(\w+)\s+([\w\[\]]+(?:\s*\([^)]+\))?)\s*(.*)$", line)
+ if m:
+ fields.append({
+ "name": m.group(1),
+ "type": m.group(2).strip(),
+ "rest": m.group(3).strip(),
+ })
+
+ tables[table_name] = {
+ "name": table_name,
+ "fields": fields,
+ "constraints": constraints,
+ "source": source_type,
+ }
+
+ return tables
+
+
+def parse_rls(path):
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ content = f.read()
+
+ table_rls = defaultdict(lambda: {"enabled": False, "policies": []})
+
+ for m in re.finditer(
+ r"ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY",
+ content, re.I,
+ ):
+ table_rls[m.group(1)]["enabled"] = True
+
+ for m in re.finditer(
+ r"CREATE\s+POLICY\s+(\w+)\s+ON\s+(?:public\.)?(\w+)\s+.*?;",
+ content, re.DOTALL | re.IGNORECASE,
+ ):
+ table_rls[m.group(2)]["policies"].append({
+ "name": m.group(1),
+ "sql": m.group(0),
+ })
+
+ return table_rls
+
+
+def parse_rpc(path):
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ content = f.read()
+
+ funcs = []
+ pattern = re.compile(
+ r"CREATE\s+OR\s+REPLACE\s+FUNCTION\s+(?:public\.)?(\w+)\s*\((.*?)\)\s*RETURNS\s+(.*?)(?:LANGUAGE\s+\w+|AS\s*\$\$|SECURITY\s+DEFINER)",
+ re.DOTALL | re.IGNORECASE,
+ )
+ for m in pattern.finditer(content):
+ funcs.append({
+ "name": m.group(1),
+ "args": re.sub(r'\s+', ' ', m.group(2).strip()),
+ "returns": re.sub(r'\s+', ' ', m.group(3).strip()),
+ })
+
+ return funcs
+
+
+def get_table_belong(table_name):
+ belongs = []
+ if table_name in CONSUMER_TABLES:
+ belongs.append("consumer")
+ if table_name in DELIVERY_TABLES:
+ belongs.append("delivery")
+ if not belongs:
+ return "admin / 系统"
+ return " / ".join(belongs)
+
+
+def module_of(table_name):
+ if table_name == "ak_users":
+ return "用户与权限"
+ if table_name in {"ml_user_profiles", "ml_user_addresses", "ml_shopping_cart", "ml_user_favorites", "ml_browse_history", "ml_search_history"}:
+ return "用户与权限"
+ if table_name in {"ak_roles", "ak_permissions", "ak_admin_roles", "ak_role_permissions"}:
+ return "用户与权限"
+ if table_name in {"ak_user_groups", "ak_user_labels", "ak_user_levels"}:
+ return "用户与权限"
+ if table_name.startswith("ml_product") or table_name.startswith("medical_mall") or table_name.startswith("ak_shipping") or table_name.startswith("ml_products"):
+ return "商品与类目"
+ if table_name in {"ml_categories", "ml_brands", "ml_shops"}:
+ return "商品与类目"
+ if table_name.startswith("ml_order"):
+ return "订单"
+ if table_name.startswith("ml_delivery") or table_name == "ml_delivery_drivers":
+ return "配送与物流"
+ if table_name.startswith("homecare"):
+ return "居家护理"
+ if table_name.startswith("ml_system"):
+ return "系统配置"
+ if table_name.startswith("ml_cms") or table_name.startswith("ml_articles") or table_name.startswith("ml_article") or table_name.startswith("ak_diy"):
+ return "内容与装修"
+ if table_name.startswith("ml_kefu"):
+ return "客服"
+ if table_name in {"ml_user_bill", "ml_user_recharge", "ml_extract", "ml_invoices"}:
+ return "财务"
+ if table_name.startswith("ml_coupon") or table_name.startswith("ml_seckill") or table_name.startswith("ml_group") or table_name.startswith("ml_bargain") or table_name.startswith("ml_live") or table_name.startswith("ml_lottery") or table_name.startswith("ml_signin") or table_name.startswith("ml_checkin") or table_name.startswith("ml_member") or table_name.startswith("ml_recharge"):
+ return "营销与促销"
+ if table_name.startswith("ak_distribution") or table_name.startswith("ak_promoter") or table_name.startswith("ak_commission"):
+ return "分销与推广"
+ return "其他"
+
+
+def main():
+ os.chdir(r"D:\骅锋")
+
+ all_tables = OrderedDict()
+
+ # 按优先级解析:auth -> core -> ext
+ for path, stype in SOURCES:
+ parsed = parse_sql_file(path, stype)
+ for name, info in parsed.items():
+ if name not in all_tables:
+ all_tables[name] = info
+
+ rls_data = parse_rls(RLS_PATH)
+ rpc_funcs = parse_rpc(RPC_PATH)
+
+ module_map = defaultdict(list)
+ for t in all_tables.values():
+ module_map[module_of(t["name"])].append(t)
+
+ # 排序模块
+ module_order = [
+ "用户与权限", "商品与类目", "订单", "配送与物流",
+ "居家护理", "营销与促销", "分销与推广", "财务",
+ "内容与装修", "客服", "系统配置", "其他"
+ ]
+
+ lines = []
+ lines.append("# 医疗-consumer & 医疗-delivery 数据库对接文档")
+ lines.append("")
+ lines.append("> **生成日期**:2026-06-01")
+ lines.append("> **适用范围**:医疗-consumer(消费者端)+ 医疗-delivery(配送端)")
+ lines.append("> **数据库**:PostgreSQL (Supabase)")
+ lines.append("> **规范**:所有用户侧表已启用 RLS(行级安全),全局/管理后台查询请走 RPC(SECURITY DEFINER)")
+ lines.append("")
+
+ lines.append("## 目录")
+ lines.append("")
+ lines.append("- [通用规范说明](#通用规范说明)")
+ for mod in module_order:
+ if mod in module_map and module_map[mod]:
+ anchor = mod.replace(" ", "-").replace("与", "")
+ lines.append(f"- [{mod}](#{anchor})")
+ lines.append("- [完整对接 SQL 汇总](#完整对接-sql-汇总)")
+ lines.append("- [关键 RPC 清单](#关键-rpc-清单)")
+ lines.append("")
+
+ lines.append("## 通用规范说明")
+ lines.append("")
+ lines.append("### 软删除标准")
+ lines.append("本项目所有业务表默认采用**软删除**,标准字段如下:")
+ lines.append("- `deleted_at` (timestamptz):删除时间,NULL 表示未删除")
+ lines.append("- `deleted_by` (uuid):删除操作人")
+ lines.append("- `created_at` (timestamptz):创建时间,默认 `now()`")
+ lines.append("- `updated_at` (timestamptz):更新时间,默认 `now()`")
+ lines.append("")
+ lines.append("> **注意**:RLS 策略默认过滤 `deleted_at IS NULL` 的行,查询时无需手动加条件,但管理后台统计需视情况处理。")
+ lines.append("")
+ lines.append("### 角色字段权威口径")
+ lines.append("- 统一用户主表:`public.ak_users`")
+ lines.append("- 角色唯一权威字段:`ak_users.role`,取值:`customer`(消费者)、`merchant`(商家)、`delivery`(配送员)、`admin`(管理员)、`analytics`(数据分析师)")
+ lines.append("- 商城用户扩展档案:`public.ml_user_profiles`,与 `ak_users` 1:1 关系(`user_id` UNIQUE)")
+ lines.append("")
+ lines.append("### 状态机速查")
+ lines.append("| 状态域 | 字段名 | 关键取值 | 说明 |")
+ lines.append("|--------|--------|----------|------|")
+ lines.append("| 订单流程 | `order_status` | 1=待付款, 2=待发货, 3=待收货, 4=已完成, 5=已取消, 6=退款中, 7=已退款 | 主状态 |")
+ lines.append("| 支付状态 | `payment_status` | 1=未支付, 2=已支付, 3=部分退款, 4=全额退款 | 支付线 |")
+ lines.append("| 物流状态 | `shipping_status` | 1=未发货, 2=已发货, 3=运输中, 4=已送达 | 物流线 |")
+ lines.append("| 配送任务 | `status` | 1=待接单, 2=已接单, 3=取货中, 4=配送中, 5=已送达, 6=配送失败 | 配送端 |")
+ lines.append("")
+
+ for mod in module_order:
+ if mod not in module_map or not module_map[mod]:
+ continue
+ anchor = mod.replace(" ", "-").replace("与", "")
+ lines.append(f"## {mod}")
+ lines.append("")
+ for t in module_map[mod]:
+ tname = t["name"]
+ belong = get_table_belong(tname)
+ lines.append(f"### {tname}")
+ lines.append(f"- **所属端**:{belong}")
+ lines.append("")
+ lines.append("| 字段名 | 数据类型 | 约束/默认值 | 说明 |")
+ lines.append("|--------|----------|-------------|------|")
+ for f in t["fields"]:
+ rest = f["rest"].replace("|", "\|")
+ lines.append(f"| {f['name']} | {f['type']} | {rest} | |")
+ lines.append("")
+
+ if t["constraints"]:
+ lines.append("**约束**:")
+ for c in t["constraints"]:
+ lines.append(f"- `{c}`")
+ lines.append("")
+
+ rls = rls_data.get(tname)
+ if rls and rls["policies"]:
+ lines.append("**RLS 策略**:")
+ for p in rls["policies"]:
+ lines.append(f"- `{p['name']}`")
+ lines.append("")
+ elif rls and rls["enabled"] and not rls["policies"]:
+ lines.append("**RLS**:已启用,暂无显式策略(默认拒绝直接访问)")
+ lines.append("")
+
+ # 查找关联 RPC(函数名包含表名关键词)
+ keywords = [tname.replace("ml_", "").replace("ak_", "").replace("_", "")]
+ if tname.startswith("ml_"):
+ keywords.append(tname[3:].replace("_", ""))
+ related_rpc = []
+ for fn in rpc_funcs:
+ fn_flat = fn["name"].replace("_", "").lower()
+ for kw in keywords:
+ if kw.lower() in fn_flat:
+ related_rpc.append(fn)
+ break
+ if related_rpc:
+ lines.append("**相关 RPC**(最多展示 8 个):")
+ for fn in related_rpc[:8]:
+ args = fn["args"][:100] + "..." if len(fn["args"]) > 100 else fn["args"]
+ lines.append(f"- `{fn['name']}({args}) -> {fn['returns']}`")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ # 汇总 SQL
+ lines.append("## 完整对接 SQL 汇总")
+ lines.append("")
+ lines.append("> 以下 SQL 按执行顺序排列:Schema → RLS → RPC。请在新项目/新环境中按顺序执行。")
+ lines.append("")
+ lines.append("### 1) 核心商城 Schema(complete_mall_database.sql)")
+ lines.append("```sql")
+ with open(SOURCES[0][0], "r", encoding="utf-8", errors="ignore") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+ lines.append("### 2) 扩展模块 Schema(docs/sql/10_schema/)")
+ lines.append("```sql")
+ with open(SOURCES[1][0], "r", encoding="utf-8", errors="ignore") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+ lines.append("### 3) RLS(行级安全)")
+ lines.append("```sql")
+ with open(RLS_PATH, "r", encoding="utf-8", errors="ignore") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+ lines.append("### 4) RPC(关键函数,完整见 30_rpc 目录)")
+ lines.append("```sql")
+ with open(RPC_PATH, "r", encoding="utf-8", errors="ignore") as f:
+ lines.append(f.read())
+ lines.append("```")
+ lines.append("")
+
+ # RPC 清单
+ lines.append("## 关键 RPC 清单")
+ lines.append("")
+ lines.append("| 函数名 | 参数 | 返回 | 用途 |")
+ lines.append("|--------|------|------|------|")
+ for fn in rpc_funcs:
+ args = fn["args"].replace("\n", " ")[:80]
+ ret = fn["returns"].replace("\n", " ")[:80]
+ lines.append(f"| {fn['name']} | {args} | {ret} | |")
+ lines.append("")
+
+ with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines))
+
+ print(f"文档已生成:{OUTPUT_PATH}")
+ print(f"共汇总表:{len(all_tables)} 张,RPC 函数:{len(rpc_funcs)} 个")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/数据库对接文档.md b/docs/数据库对接文档.md
new file mode 100644
index 00000000..df2cc6a6
--- /dev/null
+++ b/docs/数据库对接文档.md
@@ -0,0 +1,12188 @@
+# 医疗-consumer & 医疗-delivery 数据库对接文档
+
+> **生成日期**:2026-06-01
+> **适用范围**:医疗-consumer(消费者端)+ 医疗-delivery(配送端)
+> **数据库**:PostgreSQL (Supabase)
+> **规范**:所有用户侧表已启用 RLS(行级安全),全局/管理后台查询请走 RPC(SECURITY DEFINER)
+
+## 目录
+
+- [通用规范说明](#通用规范说明)
+- [用户与权限](#用户权限)
+- [商品与类目](#商品类目)
+- [订单](#订单)
+- [配送与物流](#配送物流)
+- [营销与促销](#营销促销)
+- [分销与推广](#分销推广)
+- [财务](#财务)
+- [内容与装修](#内容装修)
+- [客服](#客服)
+- [系统配置](#系统配置)
+- [其他](#其他)
+- [完整对接 SQL 汇总](#完整对接-sql-汇总)
+- [关键 RPC 清单](#关键-rpc-清单)
+
+## 通用规范说明
+
+### 软删除标准
+本项目所有业务表默认采用**软删除**,标准字段如下:
+- `deleted_at` (timestamptz):删除时间,NULL 表示未删除
+- `deleted_by` (uuid):删除操作人
+- `created_at` (timestamptz):创建时间,默认 `now()`
+- `updated_at` (timestamptz):更新时间,默认 `now()`
+
+> **注意**:RLS 策略默认过滤 `deleted_at IS NULL` 的行,查询时无需手动加条件,但管理后台统计需视情况处理。
+
+### 角色字段权威口径
+- 统一用户主表:`public.ak_users`
+- 角色唯一权威字段:`ak_users.role`,取值:`customer`(消费者)、`merchant`(商家)、`delivery`(配送员)、`admin`(管理员)、`analytics`(数据分析师)
+- 商城用户扩展档案:`public.ml_user_profiles`,与 `ak_users` 1:1 关系(`user_id` UNIQUE)
+
+### 状态机速查
+| 状态域 | 字段名 | 关键取值 | 说明 |
+|--------|--------|----------|------|
+| 订单流程 | `order_status` | 1=待付款, 2=待发货, 3=待收货, 4=已完成, 5=已取消, 6=退款中, 7=已退款 | 主状态 |
+| 支付状态 | `payment_status` | 1=未支付, 2=已支付, 3=部分退款, 4=全额退款 | 支付线 |
+| 物流状态 | `shipping_status` | 1=未发货, 2=已发货, 3=运输中, 4=已送达 | 物流线 |
+| 配送任务 | `status` | 1=待接单, 2=已接单, 3=取货中, 4=配送中, 5=已送达, 6=配送失败 | 配送端 |
+
+## 用户与权限
+
+### ml_user_profiles
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| status | INTEGER | DEFAULT 1 NOT NULL, -- 1:正常 2:冻结 3:注销 4:待审核 | |
+| real_name | VARCHAR(100) | , -- 真实姓名 | |
+| id_card | VARCHAR(32) | , -- 身份证号 | |
+| business_license | VARCHAR(100) | , -- 营业执照号 | |
+| credit_score | INTEGER | DEFAULT 100, -- 信用分数 0-1000 | |
+| verification_status | INTEGER | DEFAULT 0, -- 认证状态0:未认证1:已认证2:认证失败 | |
+| verification_data | JSONB | DEFAULT '{}', -- 认证相关数据 | |
+| preferences | JSONB | DEFAULT '{}', -- 用户偏好设置 | |
+| emergency_contact | VARCHAR(200) | , -- 紧急联系人 | |
+| service_areas | JSONB | , -- 服务区域(配送员) | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_user_status CHECK (status IN (1,2,3,4))`
+- `CONSTRAINT chk_ml_verification_status CHECK (verification_status IN (0,1,2))`
+- `CONSTRAINT chk_ml_credit_score CHECK (credit_score >= 0 AND credit_score <= 1000)`
+
+---
+
+### ml_user_addresses
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| receiver_name | VARCHAR(100) | NOT NULL | |
+| receiver_phone | VARCHAR(32) | NOT NULL | |
+| province | VARCHAR(100) | NOT NULL | |
+| city | VARCHAR(100) | NOT NULL | |
+| district | VARCHAR(100) | NOT NULL | |
+| street | VARCHAR(200) | | |
+| address_detail | TEXT | NOT NULL | |
+| postal_code | VARCHAR(16) | | |
+| is_default | BOOLEAN | DEFAULT FALSE | |
+| label | VARCHAR(50) | , -- home/office/school/other | |
+| latitude | DECIMAL(10,7) | | |
+| longitude | DECIMAL(10,7) | | |
+| delivery_instructions | TEXT | | |
+| business_hours | VARCHAR(100) | | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:禁用 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_address_status CHECK (status IN (1,2))`
+
+---
+
+### ml_shopping_cart
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| sku_id | UUID | REFERENCES public.ml_product_skus(id) ON DELETE CASCADE | |
+| quantity | INTEGER | NOT NULL CHECK (quantity > 0) | |
+| selected | BOOLEAN | DEFAULT TRUE | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `UNIQUE(user_id, product_id, sku_id)`
+
+---
+
+### ml_user_favorites
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| target_type | INTEGER | NOT NULL, -- 1:商品 2:店铺 | |
+| target_id | UUID | NOT NULL | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `UNIQUE(user_id, target_type, target_id)`
+- `CONSTRAINT chk_ml_favorite_type CHECK (target_type IN (1,2))`
+
+---
+
+### ml_browse_history
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| browse_duration | INTEGER | DEFAULT 0, -- 浏览时长(秒) | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `UNIQUE(user_id, product_id)`
+
+---
+
+### ml_search_history
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| keyword | VARCHAR(200) | NOT NULL | |
+| result_count | INTEGER | DEFAULT 0 | |
+| ip_address | INET | | |
+| user_agent | TEXT | | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+### ak_roles
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL UNIQUE, -- 角色名称 (如: 超级管理员) | |
+| code | TEXT | NOT NULL UNIQUE, -- 角色编码 (如: super_admin) | |
+| description | TEXT | , -- 角色描述 | |
+| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+### ak_permissions
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| parent_id | UUID | REFERENCES public.ak_permissions(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 权限/菜单名称 | |
+| code | TEXT | NOT NULL UNIQUE, -- 权限编码 (如: order_view) | |
+| type | TEXT | NOT NULL, -- 类型: menu(菜单), button(按钮/接口) | |
+| path | TEXT | , -- 前端路由路径 (仅针对 menu) | |
+| icon | TEXT | , -- 图标 | |
+| sort_order | INTEGER | DEFAULT 0, -- 排序 | |
+| is_visible | BOOLEAN | DEFAULT TRUE, -- 菜单是否在左侧可见 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+### ak_admin_roles
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| role_id | UUID | NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE | |
+| assigned_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| assigned_by | UUID | REFERENCES public.ak_users(id) | |
+
+**约束**:
+- `UNIQUE(user_id, role_id)`
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+### ak_role_permissions
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| role_id | UUID | NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE | |
+| permission_id | UUID | NOT NULL REFERENCES public.ak_permissions(id) ON DELETE CASCADE | |
+
+**约束**:
+- `UNIQUE(role_id, permission_id)`
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+### ak_user_groups
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| remark | TEXT | NULL | |
+| status | INT | NOT NULL DEFAULT 1, -- 1:启用, 0:禁用 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | NULL | |
+
+**约束**:
+- `CONSTRAINT ak_user_groups_name_length CHECK (char_length(name) >= 1)`
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_user_group_save(p_id UUID DEFAULT NULL, p_name TEXT, p_remark TEXT DEFAULT NULL, p_status INT DEFAULT 1) -> UUID`
+- `rpc_admin_user_group_set_status(p_id UUID, p_status INT) -> BOOLEAN`
+
+---
+
+### ak_user_labels
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| color | TEXT | NULL | |
+| remark | TEXT | NULL | |
+| status | INT | NOT NULL DEFAULT 1 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | NULL | |
+
+**约束**:
+- `CONSTRAINT ak_user_labels_name_length CHECK (char_length(name) >= 1)`
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_user_label_save(p_id UUID DEFAULT NULL, p_name TEXT, p_color TEXT DEFAULT NULL, p_remark TEXT DEFAULT NULL, p_status...) -> UUID`
+- `rpc_admin_user_label_set_status(p_id UUID, p_status INT) -> BOOLEAN`
+
+---
+
+### ak_user_levels
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| level_weight | INT | NOT NULL | |
+| min_experience | INT | NOT NULL DEFAULT 0 | |
+| discount_percent | INT | NOT NULL DEFAULT 100 | |
+| is_visible | BOOLEAN | NOT NULL DEFAULT TRUE | |
+| status | INT | NOT NULL DEFAULT 1 | |
+| icon_url | TEXT | NULL | |
+| bg_image_url | TEXT | NULL | |
+| bg_style_json | JSONB | NULL | |
+| remark | TEXT | NULL | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | NULL | |
+
+**约束**:
+- `CONSTRAINT ak_user_levels_level_weight_nonnegative CHECK (level_weight >= 0)`
+- `CONSTRAINT ak_user_levels_min_experience_nonnegative CHECK (min_experience >= 0)`
+- `CONSTRAINT ak_user_levels_discount_percent_range CHECK (discount_percent BETWEEN 1 AND 100)`
+
+**RLS 策略**:
+- `ak_user_levels_public_select_visible_active`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_user_level_save(p_id UUID DEFAULT NULL, p_name TEXT, p_level_weight INT, p_min_experience INT, p_discount_percent IN...) -> UUID`
+- `rpc_admin_user_level_set_status(p_id UUID, p_status INT) -> BOOLEAN`
+- `rpc_admin_user_level_set_visible(p_id UUID, p_is_visible BOOLEAN) -> BOOLEAN`
+
+---
+
+### ak_users
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | uuid | primary key | |
+| username | text | | |
+| email | text | | |
+| gender | text | | |
+| birthday | date | | |
+| height_cm | numeric | | |
+| weight_kg | numeric | | |
+| bio | text | | |
+| avatar_url | text | | |
+| preferred_language | text | | |
+| health_goal | text | | |
+| service_address | text | | |
+| emergency_contact | text | | |
+| chronic_notes | text | | |
+| care_preference | text | | |
+| role | text | | |
+| school_id | text | | |
+| grade_id | text | | |
+| class_id | text | | |
+| created_at | timestamptz | default now() | |
+| updated_at | timestamptz | default now() | |
+
+---
+
+## 商品与类目
+
+### ml_categories
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| parent_id | UUID | REFERENCES public.ml_categories(id) | |
+| name | VARCHAR(200) | NOT NULL | |
+| slug | VARCHAR(200) | UNIQUE | |
+| description | TEXT | | |
+| icon_url | TEXT | | |
+| banner_url | TEXT | | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| level | INTEGER | DEFAULT 1 | |
+| path | TEXT[] | , -- 分类路径 | |
+| is_active | BOOLEAN | DEFAULT TRUE | |
+| seo_title | VARCHAR(200) | | |
+| seo_description | VARCHAR(500) | | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+### ml_brands
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| name | VARCHAR(200) | NOT NULL | |
+| logo_url | TEXT | | |
+| description | TEXT | | |
+| website | VARCHAR(500) | | |
+| is_active | BOOLEAN | DEFAULT TRUE | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+### ml_products
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| category_id | UUID | NOT NULL REFERENCES public.ml_categories(id) | |
+| brand_id | UUID | REFERENCES public.ml_brands(id) | |
+| product_code | VARCHAR(100) | UNIQUE NOT NULL | |
+| name | VARCHAR(500) | NOT NULL | |
+| subtitle | VARCHAR(1000) | | |
+| description | TEXT | | |
+| main_image_url | TEXT | | |
+| image_urls | JSONB | DEFAULT '[]' | |
+| video_urls | JSONB | DEFAULT '[]' | |
+| base_price | DECIMAL(12,2) | NOT NULL CHECK (base_price >= 0) | |
+| market_price | DECIMAL(12,2) | | |
+| cost_price | DECIMAL(12,2) | | |
+| total_stock | INTEGER | DEFAULT 0 CHECK (total_stock >= 0) | |
+| available_stock | INTEGER | DEFAULT 0 CHECK (available_stock >= 0) | |
+| min_order_qty | INTEGER | DEFAULT 1 CHECK (min_order_qty > 0) | |
+| max_order_qty | INTEGER | | |
+| weight | DECIMAL(10,3) | | |
+| dimensions | JSONB | , -- {length, width, height} | |
+| status | INTEGER | DEFAULT 1, -- 1:上架 2:下架 3:草稿 4:删除 | |
+| is_featured | BOOLEAN | DEFAULT FALSE | |
+| is_new | BOOLEAN | DEFAULT FALSE | |
+| is_hot | BOOLEAN | DEFAULT FALSE | |
+| view_count | INTEGER | DEFAULT 0 | |
+| sale_count | INTEGER | DEFAULT 0 | |
+| favorite_count | INTEGER | DEFAULT 0 | |
+| rating_avg | DECIMAL(3,2) | DEFAULT 0.00 CHECK (rating_avg >= 0 AND rating_avg <= 5) | |
+| rating_count | INTEGER | DEFAULT 0 | |
+| seo_title | VARCHAR(200) | | |
+| seo_description | VARCHAR(500) | | |
+| seo_keywords | TEXT[] | | |
+| slug | VARCHAR(200) | UNIQUE | |
+| tags | TEXT[] | | |
+| attributes | JSONB | DEFAULT '{}' | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| published_at | TIMESTAMP | WITH TIME ZONE | |
+
+**约束**:
+- `CONSTRAINT chk_ml_product_status CHECK (status IN (1,2,3,4))`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_product_stats(p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE) -> JSONB`
+
+---
+
+### ml_product_skus
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| sku_code | VARCHAR(100) | UNIQUE NOT NULL | |
+| specifications | JSONB | DEFAULT '{}', -- 规格组合 | |
+| price | DECIMAL(12,2) | NOT NULL CHECK (price >= 0) | |
+| market_price | DECIMAL(12,2) | | |
+| cost_price | DECIMAL(12,2) | | |
+| stock | INTEGER | DEFAULT 0 CHECK (stock >= 0) | |
+| warning_stock | INTEGER | DEFAULT 10, -- 库存预警 | |
+| image_url | TEXT | | |
+| weight | DECIMAL(10,3) | | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:禁用 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_sku_status CHECK (status IN (1,2))`
+
+---
+
+### ml_product_specs
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| spec_name | VARCHAR(100) | NOT NULL, -- 规格名称:颜色、尺寸等 | |
+| spec_values | JSONB | NOT NULL DEFAULT '[]', -- 规格值数组 | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+### ml_shops
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| merchant_id | UUID | UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| shop_name | VARCHAR(200) | NOT NULL | |
+| shop_logo | TEXT | | |
+| shop_banner | TEXT | | |
+| description | TEXT | | |
+| business_license | VARCHAR(100) | | |
+| contact_name | VARCHAR(100) | | |
+| contact_phone | VARCHAR(32) | | |
+| contact_email | VARCHAR(200) | | |
+| address | JSONB | , -- 店铺地址信息 | |
+| business_hours | JSONB | , -- 营业时间 | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:暂停 3:关闭 | |
+| product_count | INTEGER | DEFAULT 0 | |
+| order_count | INTEGER | DEFAULT 0 | |
+| rating_avg | DECIMAL(3,2) | DEFAULT 0.00 | |
+| rating_count | INTEGER | DEFAULT 0 | |
+| verified_at | TIMESTAMP | WITH TIME ZONE | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_shop_status CHECK (status IN (1,2,3))`
+
+---
+
+### ml_product_reviews
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| order_id | UUID | NOT NULL REFERENCES public.ml_orders(id) | |
+| order_item_id | UUID | NOT NULL REFERENCES public.ml_order_items(id) | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| rating | INTEGER | NOT NULL CHECK (rating >= 1 AND rating <= 5) | |
+| content | TEXT | | |
+| images | JSONB | DEFAULT '[]', -- 评价图片 | |
+| is_anonymous | BOOLEAN | DEFAULT FALSE | |
+| merchant_reply | TEXT | | |
+| merchant_replied_at | TIMESTAMP | WITH TIME ZONE | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:已删除3:已隐藏 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_review_status CHECK (status IN (1,2,3))`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_get_product_reviews(p_search_product text DEFAULT NULL, p_search_user text DEFAULT NULL, p_status integer DEFAULT NULL, ...) -> TABLE ( id uuid, product_id uuid, product_name text, product_image text, user_id uuid, username text, rating integer, content text, merchant_reply text, status integer, created_at timestamptz, total_count bigint )`
+
+---
+
+### ak_shipping_templates
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 模板名称 | |
+| calc_method | TEXT | DEFAULT 'piece', -- 计费方式: piece(件数), weight(重量), volume(体积) | |
+| is_free_shipping | BOOLEAN | DEFAULT false, -- 是否包邮 | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### medical_mall_categories
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY | |
+| name | TEXT | NOT NULL | |
+| parent_id | TEXT | NULL REFERENCES public.medical_mall_categories(id) | |
+| level | INTEGER | NOT NULL CHECK (level IN (1, 2)) | |
+| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
+| icon | TEXT | | |
+| image_url | TEXT | | |
+| description | TEXT | | |
+| scene | TEXT | NOT NULL DEFAULT 'medical_mall' | |
+| category_type | TEXT | NOT NULL DEFAULT 'normal' | |
+| compliance_type | TEXT | NOT NULL DEFAULT 'normal' | |
+| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
+| deleted_at | TIMESTAMPTZ | NULL | |
+| deleted_by | UUID | NULL REFERENCES public.ak_users(id) | |
+| restored_at | TIMESTAMPTZ | NULL | |
+| restored_by | UUID | NULL REFERENCES public.ak_users(id) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
+
+**RLS 策略**:
+- `medical_mall_categories_public_select_active`
+
+---
+
+### medical_mall_product_categories
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| category_id | TEXT | NOT NULL REFERENCES public.medical_mall_categories(id) | |
+| is_primary | BOOLEAN | NOT NULL DEFAULT FALSE | |
+| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
+| deleted_at | TIMESTAMPTZ | NULL | |
+| deleted_by | UUID | NULL REFERENCES public.ak_users(id) | |
+| restored_at | TIMESTAMPTZ | NULL | |
+| restored_by | UUID | NULL REFERENCES public.ak_users(id) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
+
+**约束**:
+- `UNIQUE (product_id, category_id)`
+
+**RLS 策略**:
+- `medical_mall_product_categories_public_select_active`
+
+---
+
+## 订单
+
+### ml_orders
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| order_no | VARCHAR(50) | UNIQUE NOT NULL | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| product_amount | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 商品金额 | |
+| discount_amount | DECIMAL(12,2) | DEFAULT 0, -- 优惠金额 | |
+| shipping_fee | DECIMAL(12,2) | DEFAULT 0, -- 运费 | |
+| total_amount | DECIMAL(12,2) | NOT NULL, -- 总金额 | |
+| paid_amount | DECIMAL(12,2) | DEFAULT 0, -- 已付金额 | |
+| shipping_address | JSONB | NOT NULL, -- 收货地址 | |
+| order_status | INTEGER | DEFAULT 1, -- 1:待付款2:待发货3:待收货4:已完成5:已取消6:退款中 7:已退款 | |
+| payment_status | INTEGER | DEFAULT 1, -- 1:未付款2:已付款3:部分退款4:全额退款 | |
+| shipping_status | INTEGER | DEFAULT 1, -- 1:未发货2:已发货3:运输中4:已送达 | |
+| paid_at | TIMESTAMP | WITH TIME ZONE | |
+| shipped_at | TIMESTAMP | WITH TIME ZONE | |
+| delivered_at | TIMESTAMP | WITH TIME ZONE | |
+| completed_at | TIMESTAMP | WITH TIME ZONE | |
+| remark | TEXT | , -- 买家备注 | |
+| merchant_memo | TEXT | , -- 商家备注 | |
+| cancel_reason | TEXT | , -- 取消原因 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_order_status CHECK (order_status IN (1,2,3,4,5,6,7))`
+- `CONSTRAINT chk_ml_payment_status CHECK (payment_status IN (1,2,3,4))`
+- `CONSTRAINT chk_ml_shipping_status CHECK (shipping_status IN (1,2,3,4))`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_order_source_stats(p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ) -> JSONB`
+- `rpc_admin_order_stats(p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ) -> JSONB`
+
+---
+
+### ml_order_items
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| order_id | UUID | NOT NULL REFERENCES public.ml_orders(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) | |
+| sku_id | UUID | REFERENCES public.ml_product_skus(id) | |
+| product_name | VARCHAR(500) | NOT NULL | |
+| sku_name | VARCHAR(500) | | |
+| specifications | JSONB | DEFAULT '{}' | |
+| image_url | TEXT | | |
+| price | DECIMAL(12,2) | NOT NULL | |
+| quantity | INTEGER | NOT NULL CHECK (quantity > 0) | |
+| total_amount | DECIMAL(12,2) | NOT NULL | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+## 配送与物流
+
+### ml_delivery_drivers
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| real_name | VARCHAR(100) | NOT NULL | |
+| id_card | VARCHAR(32) | NOT NULL | |
+| driver_license | VARCHAR(50) | | |
+| vehicle_type | INTEGER | , -- 1:电动车2:摩托车3:汽车 | |
+| vehicle_number | VARCHAR(20) | | |
+| service_areas | JSONB | DEFAULT '[]', -- 服务区域 | |
+| work_status | INTEGER | DEFAULT 1, -- 1:在线 2:忙碌 3:离线 | |
+| current_lat | DECIMAL(10,7) | | |
+| current_lng | DECIMAL(10,7) | | |
+| rating_avg | DECIMAL(3,2) | DEFAULT 0.00 | |
+| rating_count | INTEGER | DEFAULT 0 | |
+| order_count | INTEGER | DEFAULT 0 | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:暂停 3:离职 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_driver_vehicle_type CHECK (vehicle_type IN (1,2,3))`
+- `CONSTRAINT chk_ml_driver_work_status CHECK (work_status IN (1,2,3))`
+- `CONSTRAINT chk_ml_driver_status CHECK (status IN (1,2,3))`
+
+---
+
+### ml_delivery_tasks
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| order_id | UUID | UNIQUE NOT NULL REFERENCES public.ml_orders(id) | |
+| driver_id | UUID | REFERENCES public.ml_delivery_drivers(id) | |
+| pickup_address | JSONB | NOT NULL, -- 取货地址 | |
+| delivery_address | JSONB | NOT NULL, -- 配送地址 | |
+| distance | DECIMAL(8,2) | , -- 配送距离km) | |
+| estimated_time | INTEGER | , -- 预计配送时间分钟) | |
+| delivery_fee | DECIMAL(10,2) | NOT NULL DEFAULT 0 | |
+| status | INTEGER | DEFAULT 1, -- 1:待接单2:已接单3:取货中4:配送中 5:已送达 6:配送失败 | |
+| assigned_at | TIMESTAMP | WITH TIME ZONE | |
+| picked_at | TIMESTAMP | WITH TIME ZONE | |
+| delivered_at | TIMESTAMP | WITH TIME ZONE | |
+| delivery_code | VARCHAR(10) | , -- 取货中 | |
+| remark | TEXT | | |
+| failure_reason | TEXT | | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_delivery_status CHECK (status IN (1,2,3,4,5,6))`
+
+---
+
+### ml_delivery_staff
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | REFERENCES public.ak_users(id) ON DELETE SET NULL, -- 关联用户(可选) | |
+| nickname | TEXT | NOT NULL, -- 配送员名称 | |
+| avatar | TEXT | , -- 头像 | |
+| phone | TEXT | NOT NULL, -- 手机号 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 状态: 1-启用, 0-禁用 | |
+| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `delivery_staff_self_select`
+- `delivery_staff_self_update`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_delete_delivery_staff(p_id UUID) -> BOOLEAN`
+- `rpc_admin_get_delivery_staff_list(p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAULT 1, p_page_size IN...) -> JSONB`
+- `rpc_admin_get_delivery_staff_list(p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAULT 1, p_page_size IN...) -> JSONB`
+- `rpc_admin_save_delivery_staff(p_id UUID DEFAULT NULL, p_nickname TEXT DEFAULT NULL, p_avatar TEXT DEFAULT NULL, p_phone TEXT DEFAU...) -> UUID`
+- `rpc_admin_save_delivery_staff(p_id UUID DEFAULT NULL, p_uid UUID DEFAULT NULL, p_station_id UUID DEFAULT NULL, p_staff_no TEXT DEF...) -> UUID`
+
+---
+
+### ml_delivery_stations
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL, -- 提货点名称 | |
+| phone | TEXT | NOT NULL, -- 联系电话 | |
+| address | TEXT | NOT NULL, -- 详细地址 | |
+| image | TEXT | , -- 门店图片 | |
+| lng | NUMERIC(10,7) | , -- 经度 | |
+| lat | NUMERIC(10,7) | , -- 纬度 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 状态: 1-显示, 0-隐藏 | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| business_hours | JSONB | , -- 营业时间 (如: {"start": "09:00", "end": "21:00"}) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `delivery_stations_select_active`
+- `delivery_stations_select_active`
+
+---
+
+## 营销与促销
+
+### ml_coupon_templates
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| cid | SERIAL | UNIQUE NOT NULL, -- SEO友好的自增ID | |
+| merchant_id | UUID | REFERENCES public.ak_users(id), -- NULL表示平台券 | |
+| name | VARCHAR(200) | NOT NULL | |
+| description | TEXT | | |
+| coupon_type | INTEGER | NOT NULL, -- 1:满减券2:折扣券3:免运费券 | |
+| discount_type | INTEGER | NOT NULL, -- 1:固定金额 2:百分比 | |
+| discount_value | DECIMAL(12,2) | NOT NULL, -- 优惠值 | |
+| min_order_amount | DECIMAL(12,2) | DEFAULT 0, -- 最低订单金额 | |
+| max_discount_amount | DECIMAL(12,2) | , -- 最大优惠金额 | |
+| total_quantity | INTEGER | , -- 总发放数量 | |
+| per_user_limit | INTEGER | DEFAULT 1, -- 每用户限领数量 | |
+| usage_limit | INTEGER | DEFAULT 1, -- 每张券使用次数限制 | |
+| applicable_products | JSONB | DEFAULT '[]', -- 适用商品ID数组 | |
+| applicable_categories | JSONB | DEFAULT '[]', -- 适用分类ID数组 | |
+| start_time | TIMESTAMP | WITH TIME ZONE NOT NULL | |
+| end_time | TIMESTAMP | WITH TIME ZONE NOT NULL | |
+| status | INTEGER | DEFAULT 1, -- 1:正常 2:暂停 3:已结束 | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**约束**:
+- `CONSTRAINT chk_ml_coupon_type CHECK (coupon_type IN (1,2,3))`
+- `CONSTRAINT chk_ml_discount_type CHECK (discount_type IN (1,2))`
+- `CONSTRAINT chk_ml_coupon_status CHECK (status IN (1,2,3))`
+
+**RLS 策略**:
+- `ml_coupon_templates_merchant_policy`
+- `ml_coupon_templates_select_policy`
+
+---
+
+## 分销与推广
+
+### ak_commission_logs
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| source_uid | UUID | REFERENCES public.ak_users(id) ON DELETE SET NULL | |
+| order_id | UUID | REFERENCES public.ml_orders(id) ON DELETE SET NULL | |
+| order_no | VARCHAR(50) | | |
+| amount | DECIMAL(12,2) | NOT NULL DEFAULT 0 | |
+| status | TEXT | NOT NULL DEFAULT 'frozen', -- frozen/available/withdrawn/canceled | |
+| frozen_until | TIMESTAMPTZ | | |
+| remark | TEXT | | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**约束**:
+- `CONSTRAINT chk_ak_commission_amount_nonneg CHECK (amount >= 0)`
+- `CONSTRAINT chk_ak_commission_status CHECK (status IN ('frozen','available','withdrawn','canceled'))`
+
+**RLS 策略**:
+- `commission_logs_select_policy`
+
+---
+
+### ak_distribution_agent_applications
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| division_uid | UUID | NOT NULL REFERENCES public.ak_distribution_divisions(uid) | |
+| agent_name | TEXT | NOT NULL | |
+| agent_phone | TEXT | NULL | |
+| proof_images | JSONB | NULL, -- 申请凭证图片列表 | |
+| status | TEXT | NOT NULL DEFAULT 'pending', -- pending/approved/rejected | |
+| refusal_reason | TEXT | NULL | |
+| approved_at | TIMESTAMPTZ | NULL | |
+| approved_by | UUID | NULL REFERENCES public.ak_users(id) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `dist_apply_user_policy`
+
+---
+
+### ak_distribution_agents
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| uid | UUID | PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| division_uid | UUID | NOT NULL REFERENCES public.ak_distribution_divisions(uid), -- 所属事业部 | |
+| name | TEXT | NOT NULL | |
+| commission_ratio | NUMERIC(5,2) | DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100) | |
+| is_enabled | BOOLEAN | DEFAULT TRUE | |
+| end_time | TIMESTAMPTZ | | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| created_by | UUID | REFERENCES public.ak_users(id) | |
+| updated_by | UUID | REFERENCES public.ak_users(id) | |
+
+**RLS 策略**:
+- `dist_agents_select_policy`
+
+---
+
+### ak_distribution_config
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'global_config' | |
+| is_enabled | BOOLEAN | DEFAULT true, -- 分销启用 | |
+| extract_type | TEXT | DEFAULT '2', -- 分销模式: 1指定, 2人人, 3满额 | |
+| bind_type | TEXT | DEFAULT '2', -- 绑定关系: 1所有用户, 2新用户 | |
+| store_brokerage_binding_status | TEXT | DEFAULT '1', -- 绑定模式: 1永久, 2有效期, 3临时 | |
+| brokerage_poster_status | TEXT | , -- 分销海报图路径/URL | |
+| brokerage_level | INTEGER | DEFAULT 2, -- 分销层级: 1, 2 | |
+| is_area_manager | BOOLEAN | DEFAULT true, -- 事业部开关 | |
+| is_agent_apply | BOOLEAN | DEFAULT true, -- 代理商申请开关 | |
+| is_commission_window | BOOLEAN | DEFAULT true, -- 佣金悬浮窗开关 | |
+| is_self_brokerage | BOOLEAN | DEFAULT true, -- 自购返佣 | |
+| is_member_brokerage | BOOLEAN | DEFAULT false, -- 购买会员返佣 | |
+| brokerage_type | TEXT | DEFAULT '1', -- 返佣类型: 1价格, 2实付 | |
+| is_promoter_brokerage | BOOLEAN | DEFAULT true, -- 推广用户返佣 | |
+| promoter_brokerage_price | DECIMAL(10,2) | DEFAULT 2.00 | |
+| promoter_brokerage_day_max | DECIMAL(10,2) | DEFAULT -1.00 | |
+| store_brokerage_ratio | DECIMAL(10,2) | DEFAULT 20.00 | |
+| store_brokerage_two_ratio | DECIMAL(10,2) | DEFAULT 2.00 | |
+| extract_frozen_time | INTEGER | DEFAULT 1 | |
+| user_extract_min_price | DECIMAL(10,2) | DEFAULT 1.00 | |
+| extract_bank_list | TEXT | DEFAULT '中国银行' | |
+| extract_type_list | TEXT[] | DEFAULT ARRAY['bank', 'wechat', 'alipay'] | |
+| wechat_extract_type | TEXT | DEFAULT '1' | |
+| alipay_extract_type | TEXT | DEFAULT '1' | |
+| user_extract_fee | DECIMAL(10,2) | DEFAULT 0.00 | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_by | UUID | REFERENCES auth.users(id) | |
+
+**RLS 策略**:
+- `dist_config_select_policy`
+
+---
+
+### ak_distribution_division_applications
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| division_id | UUID | NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 申请人填写的代理商名称 | |
+| phone | TEXT | NOT NULL, -- 申请人联系电话 | |
+| images | JSONB | DEFAULT '[]'::jsonb, -- 申请附件图片 (数组) | |
+| status | INTEGER | DEFAULT 1, -- 状态: 1待审核, 2已同意, 3已拒绝 | |
+| admin_remark | TEXT | , -- 审核备注 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_distribution_divisions
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| uid | UUID | PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| invite_code | TEXT | UNIQUE NOT NULL | |
+| commission_ratio | NUMERIC(5,2) | DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100) | |
+| is_enabled | BOOLEAN | DEFAULT TRUE | |
+| end_time | TIMESTAMPTZ | | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| created_by | UUID | REFERENCES public.ak_users(id) | |
+| updated_by | UUID | REFERENCES public.ak_users(id) | |
+
+**RLS 策略**:
+- `dist_divisions_select_policy`
+
+---
+
+### ak_distribution_level
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL, -- 等级名称 | |
+| level | INTEGER | NOT NULL UNIQUE, -- 等级权重/数字(如1, 2, 3) | |
+| percent1 | DECIMAL(10,2) | DEFAULT 0, -- 一级分佣比例 (%) | |
+| percent2 | DECIMAL(10,2) | DEFAULT 0, -- 二级分佣比例 (%) | |
+| task_total | INTEGER | DEFAULT 0, -- 任务总数 | |
+| task_finish | INTEGER | DEFAULT 0, -- 需完成数量(升级门槛) | |
+| is_visible | BOOLEAN | DEFAULT true, -- 是否显示 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `dist_level_select_policy`
+
+---
+
+### ak_promoter_relations
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| inviter_uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| bind_time | TIMESTAMPTZ | DEFAULT now() | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**约束**:
+- `CONSTRAINT chk_ak_promoter_relations_no_self CHECK (uid <> inviter_uid)`
+- `CONSTRAINT uq_ak_promoter_relations_uid UNIQUE (uid)`
+
+**RLS 策略**:
+- `promoter_relations_select_policy`
+
+---
+
+## 财务
+
+### ml_extract
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| real_name | TEXT | NULL, -- 提现人姓名快照 | |
+| extract_type | TEXT | NOT NULL, -- 提现方式: alipay, wechat, bank | |
+| alipay_code | TEXT | NULL, -- 支付宝账号 | |
+| wechat_code | TEXT | NULL, -- 微信账号 | |
+| bank_code | TEXT | NULL, -- 银行卡号 | |
+| bank_address | TEXT | NULL, -- 开户行地址 | |
+| extract_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 申请提现金额 | |
+| service_fee | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 提现手续费 | |
+| balance | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 提现时的余额快照 | |
+| status | SMALLINT | NOT NULL DEFAULT 0, -- 状态: 0:待审核, 1:已通过, -1:已驳回 | |
+| refusal_reason | TEXT | NULL, -- 驳回原因 | |
+| admin_id | UUID | NULL REFERENCES public.ak_users(id), -- 审核人ID | |
+| payment_time | TIMESTAMPTZ | NULL, -- 打款/到账时间 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_extract_user_select`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_extract_list(p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_status SMALLINT DEFAULT NULL, p_start_ti...) -> JSONB`
+- `rpc_admin_extract_review(p_extract_id UUID, p_status SMALLINT, -- 1: 通过, -1: 驳回 p_refusal_reason TEXT DEFAULT NULL) -> VOID`
+
+---
+
+### ml_invoices
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| order_no | TEXT | NOT NULL, -- 关联订单号 | |
+| order_amount | DECIMAL(12,2) | NOT NULL, -- 订单金额 | |
+| invoice_type | SMALLINT | NOT NULL DEFAULT 1, -- 1: 电子普通发票, 2: 增值税专用发票 | |
+| header_type | SMALLINT | NOT NULL DEFAULT 1, -- 1: 个人, 2: 企业 | |
+| header_name | TEXT | NOT NULL, -- 发票抬头 | |
+| tax_id | TEXT | NULL, -- 企业税号 | |
+| email | TEXT | NULL, -- 接收邮箱 | |
+| remark | TEXT | NULL, -- 备注 | |
+| status | SMALLINT | NOT NULL DEFAULT 0, -- 0: 待开票, 1: 已开票, -1: 已拒绝 | |
+| refusal_reason | TEXT | NULL, -- 驳回原因 | |
+| invoice_url | TEXT | NULL, -- 电子发票文件路径/URL | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_invoices_user_select`
+
+---
+
+### ml_user_bill
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| link_id | TEXT | NULL, -- 关联业务ID(订单号、提现ID、充值ID等) | |
+| pm | SMALLINT | NOT NULL DEFAULT 1, -- 0:支出, 1:收入 | |
+| title | TEXT | NOT NULL, -- 流水标题(如:商品购买、充值、提现) | |
+| category | TEXT | NOT NULL, -- 业务大类(如:balance-余额, integral-积分, brokerage-佣金) | |
+| type | TEXT | NOT NULL, -- 业务子类型(如:recharge, extract, pay, refund, system_add, system_sub) | |
+| number | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 变动金额 | |
+| balance | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 变动后的余额快照 | |
+| mark | TEXT | NULL, -- 备注 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 状态(1:有效, 0:无效/冲正) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_user_bill_user_select`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_user_bill_list(p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_category VARCHAR DEFAULT NULL, p_type VA...) -> JSONB`
+
+---
+
+### ml_user_recharge
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) | |
+| order_no | TEXT | UNIQUE NOT NULL, -- 充值订单号(cz开头) | |
+| recharge_type | TEXT | NOT NULL, -- 充值渠道: wechat, alipay, system (后台补单) | |
+| price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 实际充值金额 | |
+| give_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 赠送金额 | |
+| paid | SMALLINT | NOT NULL DEFAULT 0, -- 支付状态: 0:未支付, 1:已支付 | |
+| pay_time | TIMESTAMPTZ | NULL, -- 支付时间 | |
+| channel_trade_no | TEXT | NULL, -- 外部渠道流水号 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 记录状态: 1:正常, 0:逻辑删除 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_user_recharge_user_select`
+
+---
+
+## 内容与装修
+
+### ml_article_categories
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| icon | TEXT | NULL | |
+| sort | INTEGER | NOT NULL DEFAULT 0 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 1: 启用, 0: 禁用 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_article_categories_select_active`
+
+---
+
+### ml_articles
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| category_id | UUID | NOT NULL REFERENCES public.ml_article_categories(id) | |
+| title | TEXT | NOT NULL | |
+| author | TEXT | NULL | |
+| image | TEXT | NULL, -- 文章封面图 | |
+| description | TEXT | NULL, -- 文章简介 | |
+| content | TEXT | NOT NULL, -- 文章内容 (富文本) | |
+| status | SMALLINT | NOT NULL DEFAULT 0, -- 0: 未发布, 1: 已发布 | |
+| views | INTEGER | NOT NULL DEFAULT 0, -- 浏览量 | |
+| is_banner | BOOLEAN | NOT NULL DEFAULT FALSE, -- 是否展示在 banner | |
+| is_hot | BOOLEAN | NOT NULL DEFAULT FALSE, -- 是否热门 | |
+| linked_product_id | UUID | NULL, -- 关联商品ID (可选) | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_articles_select_published`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_article_save(p_id UUID DEFAULT NULL, p_category_id UUID DEFAULT NULL, p_title TEXT DEFAULT NULL, p_author TEXT DE...) -> UUID`
+- `rpc_admin_article_set_status(p_id UUID, p_status SMALLINT) -> BOOLEAN`
+
+---
+
+### ak_diy_pages
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| type | TEXT | NOT NULL, -- home: 首页, topic: 专题页, user: 个人中心 | |
+| config | JSONB | NOT NULL DEFAULT '{}'::jsonb, -- 核心布局配置 (组件列表及参数) | |
+| is_home | BOOLEAN | NOT NULL DEFAULT FALSE, -- 是否为生效首页 | |
+| is_active | BOOLEAN | NOT NULL DEFAULT TRUE, -- 是否启用 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| created_by | UUID | REFERENCES public.ak_users(id) | |
+| updated_by | UUID | REFERENCES public.ak_users(id) | |
+
+**RLS 策略**:
+- `diy_pages_select_active`
+
+---
+
+## 客服
+
+### ml_kefu_accounts
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id), -- 关联主用户表 | |
+| nickname | TEXT | NOT NULL, -- 客服昵称 | |
+| avatar | TEXT | NULL, -- 客服头像 | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 1:启用, 0:禁用 | |
+| is_online | BOOLEAN | NOT NULL DEFAULT FALSE | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_kefu_account_save(p_id UUID DEFAULT NULL, p_user_id UUID DEFAULT NULL, p_nickname TEXT DEFAULT NULL, p_avatar TEXT DEF...) -> UUID`
+- `rpc_admin_kefu_account_set_status(p_id UUID, p_status SMALLINT) -> BOOLEAN`
+
+---
+
+### ml_kefu_word_categories
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| name | TEXT | NOT NULL | |
+| sort | INT | NOT NULL DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+### ml_kefu_words
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| category_id | UUID | NOT NULL REFERENCES public.ml_kefu_word_categories(id) ON DELETE CASCADE | |
+| title | TEXT | NOT NULL | |
+| content | TEXT | NOT NULL | |
+| sort | INT | NOT NULL DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_kefu_word_save(p_id UUID DEFAULT NULL, p_category_id UUID DEFAULT NULL, p_title TEXT DEFAULT NULL, p_content TEXT D...) -> UUID`
+
+---
+
+### ml_kefu_feedbacks
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| user_id | UUID | NULL REFERENCES public.ak_users(id), -- 允许匿名留言 | |
+| nickname | TEXT | NULL | |
+| phone | TEXT | NULL | |
+| content | TEXT | NOT NULL | |
+| status | SMALLINT | NOT NULL DEFAULT 0, -- 0:未处理, 1:已处理 | |
+| reply_content | TEXT | NULL, -- 管理员回复内容 | |
+| processed_at | TIMESTAMPTZ | NULL, -- 处理时间 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS 策略**:
+- `ml_kefu_feedbacks_user_insert`
+- `ml_kefu_feedbacks_user_select`
+
+---
+
+### ml_kefu_auto_replies
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| keyword | TEXT | NOT NULL | |
+| content | TEXT | NOT NULL | |
+| reply_type | TEXT | NOT NULL DEFAULT 'text', -- text, image | |
+| status | SMALLINT | NOT NULL DEFAULT 1, -- 1:开启, 0:关闭 | |
+| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
+
+**RLS**:已启用,暂无显式策略(默认拒绝直接访问)
+
+---
+
+## 系统配置
+
+### ml_system_configs
+- **所属端**:consumer / delivery
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| config_key | VARCHAR(100) | UNIQUE NOT NULL | |
+| config_value | JSONB | | |
+| description | TEXT | | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| updated_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+**RLS 策略**:
+- `system_configs_select_policy`
+
+**相关 RPC**(最多展示 8 个):
+- `rpc_admin_system_config_save(p_key TEXT, p_value JSONB, p_description TEXT DEFAULT NULL) -> BOOLEAN`
+
+---
+
+## 其他
+
+### ml_user_coupons
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| user_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| template_id | UUID | NOT NULL REFERENCES public.ml_coupon_templates(id) | |
+| coupon_code | VARCHAR(50) | UNIQUE NOT NULL | |
+| status | INTEGER | DEFAULT 1, -- 1:未使用2:已使用3:已过期 | |
+| used_at | TIMESTAMP | WITH TIME ZONE | |
+| order_id | UUID | REFERENCES public.ml_orders(id) | |
+| received_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+| expire_at | TIMESTAMP | WITH TIME ZONE NOT NULL | |
+
+**约束**:
+- `CONSTRAINT chk_ml_user_coupon_status CHECK (status IN (1,2,3))`
+
+---
+
+### ml_regions
+- **所属端**:consumer
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT uuid_generate_v4() | |
+| parent_id | UUID | REFERENCES public.ml_regions(id) | |
+| name | VARCHAR(100) | NOT NULL | |
+| code | VARCHAR(20) | | |
+| level | INTEGER | NOT NULL, -- 1:省份 2:城市 3:区县 4:街道 | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| is_active | BOOLEAN | DEFAULT TRUE | |
+| created_at | TIMESTAMP | WITH TIME ZONE DEFAULT NOW() | |
+
+---
+
+### ak_seckill_activities
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| title | TEXT | NOT NULL, -- 活动标题 | |
+| single_limit | INTEGER | DEFAULT 1, -- 单次限购 | |
+| total_limit | INTEGER | DEFAULT 10, -- 总购买数量限制 | |
+| product_count | INTEGER | DEFAULT 0, -- 包含商品数量 | |
+| time_range | TEXT | NOT NULL, -- 活动时段 (如 "06:00-24:00") | |
+| start_date | TIMESTAMPTZ | NOT NULL, -- 开始日期 | |
+| end_date | TIMESTAMPTZ | NOT NULL, -- 结束日期 | |
+| status | BOOLEAN | DEFAULT true, -- 状态: true开启, false关闭 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `seckill_select_policy`
+
+---
+
+### ak_combination_activities
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id), -- 开团团长 | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id), -- 拼团商品 | |
+| people | INTEGER | DEFAULT 2, -- 几人团 | |
+| count_people | INTEGER | DEFAULT 1, -- 当前几人参加 | |
+| start_time | TIMESTAMPTZ | DEFAULT now(), -- 开团时间 | |
+| stop_time | TIMESTAMPTZ | NOT NULL, -- 结束时间 | |
+| status | TEXT | NOT NULL DEFAULT 'ongoing', -- ongoing进行中, pending未完成, ended已成功 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**约束**:
+- `CONSTRAINT chk_comb_status CHECK (status IN ('ongoing', 'pending', 'ended'))`
+
+**RLS 策略**:
+- `combination_select_policy`
+
+---
+
+### ak_marketing_bargains
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| title | TEXT | NOT NULL, -- 活动标题 | |
+| min_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 砍价最低价 | |
+| stock | INTEGER | DEFAULT 0, -- 活动库存 | |
+| start_time | TIMESTAMPTZ | NOT NULL, -- 开始时间 | |
+| stop_time | TIMESTAMPTZ | NOT NULL, -- 结束时间 | |
+| status | BOOLEAN | DEFAULT true, -- 状态: true开启, false关闭 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `bargain_select_policy`
+
+---
+
+### ak_marketing_groupbuys
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| title | TEXT | NOT NULL, -- 活动标题 | |
+| price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 团购价格 | |
+| people | INTEGER | DEFAULT 2, -- 成团人数要求 | |
+| stock | INTEGER | DEFAULT 0, -- 活动库存 | |
+| start_time | TIMESTAMPTZ | NOT NULL, -- 开始时间 | |
+| stop_time | TIMESTAMPTZ | NOT NULL, -- 结束时间 | |
+| status | BOOLEAN | DEFAULT true, -- 状态: true开启, false关闭 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `groupbuy_select_policy`
+
+---
+
+### ak_marketing_live_products
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| live_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 直播专属价 | |
+| stock | INTEGER | DEFAULT 0, -- 直播可用库存 | |
+| audit_status | INTEGER | DEFAULT 1, -- 审核状态: 1待审核, 2审核通过, 3审核驳回 | |
+| is_show | BOOLEAN | DEFAULT true, -- 是否在直播间显示 | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_marketing_lotteries
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 活动名称 | |
+| type | INTEGER | DEFAULT 1, -- 活动类型: 1积分抽奖, 2订单评价, 3订单支付 | |
+| start_time | TIMESTAMPTZ | NOT NULL, -- 开始时间 | |
+| end_time | TIMESTAMPTZ | NOT NULL, -- 结束时间 | |
+| is_open | BOOLEAN | DEFAULT true, -- 是否开启 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `lottery_select_policy`
+
+---
+
+### ak_marketing_lottery_prizes
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| lottery_id | UUID | NOT NULL REFERENCES public.ak_marketing_lotteries(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 奖品名称 | |
+| prize_type | TEXT | NOT NULL, -- 奖品类型: points, balance, coupon, physical | |
+| amount | DECIMAL(12,2) | DEFAULT 0, -- 奖励面值/数量 | |
+| stock | INTEGER | DEFAULT 0, -- 奖品库存 | |
+| probability | DECIMAL(5,2) | DEFAULT 0, -- 中奖概率 (0-100) | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_marketing_live_anchors
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| nickname | TEXT | NOT NULL, -- 主播昵称 | |
+| wechat | TEXT | , -- 微信号 | |
+| phone | TEXT | , -- 联系电话 | |
+| avatar_url | TEXT | , -- 头像 | |
+| status | BOOLEAN | DEFAULT true, -- 状态 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `live_anchors_select_policy`
+
+---
+
+### ak_marketing_live_rooms
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| anchor_id | UUID | REFERENCES public.ak_marketing_live_anchors(id) ON DELETE SET NULL | |
+| name | TEXT | NOT NULL, -- 直播间名称 | |
+| background_url | TEXT | , -- 背景图 | |
+| share_img_url | TEXT | , -- 分享图 | |
+| start_time | TIMESTAMPTZ | NOT NULL, -- 开始时间 | |
+| end_time | TIMESTAMPTZ | NOT NULL, -- 计划结束时间 | |
+| sort | INTEGER | DEFAULT 0, -- 排序 | |
+| type | TEXT | DEFAULT 'phone', -- 类型: phone手机直播等 | |
+| like_enabled | BOOLEAN | DEFAULT true, -- 开启点赞 | |
+| sale_enabled | BOOLEAN | DEFAULT true, -- 开启卖货 | |
+| comment_enabled | BOOLEAN | DEFAULT true, -- 开启评论 | |
+| is_show | BOOLEAN | DEFAULT true, -- 是否显示 | |
+| live_status | INTEGER | DEFAULT 1, -- 1未开始, 2直播中, 3暂停, 4已结束 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `live_rooms_select_policy`
+
+---
+
+### ak_marketing_checkin_configs
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'checkin_config' | |
+| merchant_id | UUID | NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| is_open | BOOLEAN | DEFAULT true, -- 签到开关 | |
+| mode | TEXT | DEFAULT 'none', -- 签到模式: none(无限制), week(周循环), month(月循环) | |
+| notice_enabled | BOOLEAN | DEFAULT false, -- 签到提醒开关 | |
+| integral_reward | INTEGER | DEFAULT 10, -- 每日签到赠送积分 | |
+| exp_reward | INTEGER | DEFAULT 1, -- 每日签到赠送经验 | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_by | UUID | REFERENCES auth.users(id) | |
+
+---
+
+### ak_marketing_newcomer_config
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'newcomer_config' | |
+| merchant_id | UUID | NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| balance_reward | DECIMAL(12,2) | DEFAULT 0.00, -- 赠送余额 | |
+| integral_reward | INTEGER | DEFAULT 0, -- 赠送积分 | |
+| coupons_json | JSONB | DEFAULT '[]'::jsonb | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_by | UUID | REFERENCES auth.users(id) | |
+
+**RLS 策略**:
+- `newcomer_config_select_policy`
+
+---
+
+### ak_marketing_signin_logs
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| uid | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| points | INTEGER | NOT NULL DEFAULT 0, -- 本次签到获得的积分 | |
+| is_continuous_reward | BOOLEAN | DEFAULT false, -- 是否包含连续签到额外奖励 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `signin_logs_user_policy`
+
+---
+
+### ak_marketing_member_types
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 会员名 (如: 月卡, 年卡) | |
+| duration_days | INTEGER | DEFAULT 30, -- 有效期(天),0表示永久 | |
+| price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 原价 | |
+| discount_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 优惠价/实际支付价 | |
+| is_open | BOOLEAN | DEFAULT true, -- 是否开启 | |
+| sort_order | INTEGER | DEFAULT 0, -- 排序 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `member_types_select_policy`
+
+---
+
+### ak_marketing_member_rights
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL, -- 权益名称 | |
+| description | TEXT | , -- 权益简介 | |
+| icon_url | TEXT | , -- 权益图标 | |
+| is_show | BOOLEAN | DEFAULT true, -- 是否展示 | |
+| sort_order | INTEGER | DEFAULT 0, -- 排序 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `member_rights_select_policy`
+
+---
+
+### ak_marketing_member_config
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'member_config' | |
+| merchant_id | UUID | NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| is_enabled | BOOLEAN | DEFAULT true, -- 是否开启付费会员功能 | |
+| bg_img_url | TEXT | , -- 会员期内背景图 | |
+| expire_bg_img_url | TEXT | , -- 会员到期背景图 | |
+| rules_description | TEXT | , -- 会员规则说明文本 | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `member_config_select_policy`
+
+---
+
+### ak_recharge_configs
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'recharge_config' | |
+| merchant_id | UUID | NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| balance_enabled | BOOLEAN | DEFAULT true, -- 余额功能是否启用 | |
+| recharge_notice | TEXT | , -- 充值注意事项说明 | |
+| mp_recharge_enabled | BOOLEAN | DEFAULT false, -- 小程序充值开关 | |
+| min_recharge_amount | DECIMAL(12,2) | DEFAULT 0.01, -- 最低充值金额 | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `recharge_config_select_policy`
+
+---
+
+### ak_recharge_quotas
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 售价(实际充值金额) | |
+| bonus_price | DECIMAL(12,2) | NOT NULL DEFAULT 0, -- 赠送金额 | |
+| is_open | BOOLEAN | DEFAULT true, -- 是否可用 | |
+| sort_order | INTEGER | DEFAULT 0, -- 排序 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**RLS 策略**:
+- `recharge_quotas_select_policy`
+
+---
+
+### ak_signin_configs
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | TEXT | PRIMARY KEY DEFAULT 'signin_config', -- 每个商家一个配置记录 | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| is_enabled | BOOLEAN | DEFAULT true, -- 签到功能是否启用 | |
+| daily_points | INTEGER | DEFAULT 10, -- 每日签到固定奖励积分 | |
+| continuous_rewards | JSONB | DEFAULT '[]'::jsonb | |
+| rules_description | TEXT | DEFAULT '1.每日签到可获得积分奖励;\n2.连续签到满足天数可获得额外阶梯奖励;\n3.签到中断将重新从第一天开始计算。' | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_by | UUID | REFERENCES auth.users(id) | |
+
+**约束**:
+- `UNIQUE(merchant_id)`
+
+**RLS 策略**:
+- `signin_config_select_policy`
+
+---
+
+### ak_product_label_groups
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_product_labels
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| group_id | UUID | REFERENCES public.ak_product_label_groups(id) ON DELETE SET NULL | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| is_active | BOOLEAN | DEFAULT true | |
+| show_in_mobile | BOOLEAN | DEFAULT true | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_product_member_prices
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| product_id | UUID | NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE | |
+| sku_id | UUID | NOT NULL REFERENCES public.ml_product_skus(id) ON DELETE CASCADE | |
+| level_id | UUID | NOT NULL REFERENCES public.ak_user_levels(id) ON DELETE CASCADE | |
+| member_price | DECIMAL(12,2) | NOT NULL CHECK (member_price >= 0) | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+**约束**:
+- `UNIQUE(merchant_id, sku_id, level_id)`
+
+---
+
+### ak_product_protections
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| description | TEXT | NOT NULL DEFAULT '' | |
+| icon_url | TEXT | | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| is_active | BOOLEAN | DEFAULT true | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_product_spec_templates
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| specs | TEXT | NOT NULL DEFAULT '' | |
+| attrs | TEXT | NOT NULL DEFAULT '' | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| is_active | BOOLEAN | DEFAULT true | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+### ak_product_param_templates
+- **所属端**:admin / 系统
+
+| 字段名 | 数据类型 | 约束/默认值 | 说明 |
+|--------|----------|-------------|------|
+| id | UUID | PRIMARY KEY DEFAULT gen_random_uuid() | |
+| merchant_id | UUID | NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE | |
+| name | TEXT | NOT NULL | |
+| sort_order | INTEGER | DEFAULT 0 | |
+| params | JSONB | NOT NULL DEFAULT '[]'::jsonb | |
+| is_active | BOOLEAN | DEFAULT true | |
+| created_at | TIMESTAMPTZ | DEFAULT now() | |
+| updated_at | TIMESTAMPTZ | DEFAULT now() | |
+
+---
+
+## 完整对接 SQL 汇总
+
+> 以下 SQL 按执行顺序排列:Schema → RLS → RPC。请在新项目/新环境中按顺序执行。
+
+### 1) 核心商城 Schema(complete_mall_database.sql)
+```sql
+-- =====================================================================================
+-- 电商商城系统完整数据库设计(PostgreSQL + Supabase)
+-- 表名前缀: ml_ (mall)
+-- 复用主表 ak_users (用户主表)
+-- 包含: 表结构、索引、触发器、RLS策略、视图、函数
+-- =====================================================================================
+
+-- =====================================================================================
+-- 1. 基础配置和扩展
+-- =====================================================================================
+
+-- 启用必要的扩展
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+CREATE EXTENSION IF NOT EXISTS "pg_stat_statements";
+CREATE EXTENSION IF NOT EXISTS "btree_gin";
+
+-- =====================================================================================
+-- 2. 用户扩展表
+-- =====================================================================================
+
+-- 商城用户扩展信息表
+CREATE TABLE public.ml_user_profiles (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ status INTEGER DEFAULT 1 NOT NULL, -- 1:正常 2:冻结 3:注销 4:待审核
+ real_name VARCHAR(100), -- 真实姓名
+ id_card VARCHAR(32), -- 身份证号
+ business_license VARCHAR(100), -- 营业执照号
+ credit_score INTEGER DEFAULT 100, -- 信用分数 0-1000
+ verification_status INTEGER DEFAULT 0, -- 认证状态0:未认证1:已认证2:认证失败
+ verification_data JSONB DEFAULT '{}', -- 认证相关数据
+ preferences JSONB DEFAULT '{}', -- 用户偏好设置
+ emergency_contact VARCHAR(200), -- 紧急联系人
+ service_areas JSONB, -- 服务区域(配送员)
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_user_status CHECK (status IN (1,2,3,4)),
+ CONSTRAINT chk_ml_verification_status CHECK (verification_status IN (0,1,2)),
+ CONSTRAINT chk_ml_credit_score CHECK (credit_score >= 0 AND credit_score <= 1000)
+);
+
+COMMENT ON TABLE public.ml_user_profiles IS '商城用户扩展信息表';
+COMMENT ON COLUMN public.ml_user_profiles.status IS '用户状态:1正常 2冻结 3注销 4待审核';
+
+-- 用户地址表
+CREATE TABLE public.ml_user_addresses (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ receiver_name VARCHAR(100) NOT NULL,
+ receiver_phone VARCHAR(32) NOT NULL,
+ province VARCHAR(100) NOT NULL,
+ city VARCHAR(100) NOT NULL,
+ district VARCHAR(100) NOT NULL,
+ street VARCHAR(200),
+ address_detail TEXT NOT NULL,
+ postal_code VARCHAR(16),
+ is_default BOOLEAN DEFAULT FALSE,
+ label VARCHAR(50), -- home/office/school/other
+ latitude DECIMAL(10,7),
+ longitude DECIMAL(10,7),
+ delivery_instructions TEXT,
+ business_hours VARCHAR(100),
+ status INTEGER DEFAULT 1, -- 1:正常 2:禁用
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_address_status CHECK (status IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_user_addresses IS '用户地址表;
+
+-- =====================================================================================
+-- 3. 商品管理模块
+-- =====================================================================================
+
+-- 商品分类表
+CREATE TABLE public.ml_categories (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ parent_id UUID REFERENCES public.ml_categories(id),
+ name VARCHAR(200) NOT NULL,
+ slug VARCHAR(200) UNIQUE,
+ description TEXT,
+ icon_url TEXT,
+ banner_url TEXT,
+ sort_order INTEGER DEFAULT 0,
+ level INTEGER DEFAULT 1,
+ path TEXT[], -- 分类路径
+ is_active BOOLEAN DEFAULT TRUE,
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_categories IS '商品分类表;
+
+-- 品牌表
+CREATE TABLE public.ml_brands (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ name VARCHAR(200) NOT NULL,
+ logo_url TEXT,
+ description TEXT,
+ website VARCHAR(500),
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_brands IS '品牌表;
+
+-- 商品表
+CREATE TABLE public.ml_products (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+ category_id UUID NOT NULL REFERENCES public.ml_categories(id),
+ brand_id UUID REFERENCES public.ml_brands(id),
+ product_code VARCHAR(100) UNIQUE NOT NULL,
+ name VARCHAR(500) NOT NULL,
+ subtitle VARCHAR(1000),
+ description TEXT,
+ main_image_url TEXT,
+ image_urls JSONB DEFAULT '[]',
+ video_urls JSONB DEFAULT '[]',
+
+ -- 价格信息
+ base_price DECIMAL(12,2) NOT NULL CHECK (base_price >= 0),
+ market_price DECIMAL(12,2),
+ cost_price DECIMAL(12,2),
+
+ -- 库存信息
+ total_stock INTEGER DEFAULT 0 CHECK (total_stock >= 0),
+ available_stock INTEGER DEFAULT 0 CHECK (available_stock >= 0),
+ min_order_qty INTEGER DEFAULT 1 CHECK (min_order_qty > 0),
+ max_order_qty INTEGER,
+
+ -- 基础属性
+ weight DECIMAL(10,3),
+ dimensions JSONB, -- {length, width, height}
+
+ -- 状态
+ status INTEGER DEFAULT 1, -- 1:上架 2:下架 3:草稿 4:删除
+ is_featured BOOLEAN DEFAULT FALSE,
+ is_new BOOLEAN DEFAULT FALSE,
+ is_hot BOOLEAN DEFAULT FALSE,
+
+ -- 统计
+ view_count INTEGER DEFAULT 0,
+ sale_count INTEGER DEFAULT 0,
+ favorite_count INTEGER DEFAULT 0,
+ rating_avg DECIMAL(3,2) DEFAULT 0.00 CHECK (rating_avg >= 0 AND rating_avg <= 5),
+ rating_count INTEGER DEFAULT 0,
+
+ -- SEO
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ seo_keywords TEXT[],
+ slug VARCHAR(200) UNIQUE,
+
+ -- 其他
+ tags TEXT[],
+ attributes JSONB DEFAULT '{}',
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ published_at TIMESTAMP WITH TIME ZONE,
+
+ CONSTRAINT chk_ml_product_status CHECK (status IN (1,2,3,4))
+);
+
+COMMENT ON TABLE public.ml_products IS '商品表;
+
+-- 商品SKU表
+CREATE TABLE public.ml_product_skus (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_code VARCHAR(100) UNIQUE NOT NULL,
+ specifications JSONB DEFAULT '{}', -- 规格组合
+ price DECIMAL(12,2) NOT NULL CHECK (price >= 0),
+ market_price DECIMAL(12,2),
+ cost_price DECIMAL(12,2),
+ stock INTEGER DEFAULT 0 CHECK (stock >= 0),
+ warning_stock INTEGER DEFAULT 10, -- 库存预警
+ image_url TEXT,
+ weight DECIMAL(10,3),
+ status INTEGER DEFAULT 1, -- 1:正常 2:禁用
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_sku_status CHECK (status IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_product_skus IS '商品SKU表;
+
+-- 商品规格表
+CREATE TABLE public.ml_product_specs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ spec_name VARCHAR(100) NOT NULL, -- 规格名称:颜色、尺寸等
+ spec_values JSONB NOT NULL DEFAULT '[]', -- 规格值数组
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_product_specs IS '商品规格表;
+
+-- =====================================================================================
+-- 4. 店铺管理模块
+-- =====================================================================================
+
+-- 店铺信息表
+CREATE TABLE public.ml_shops (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ shop_name VARCHAR(200) NOT NULL,
+ shop_logo TEXT,
+ shop_banner TEXT,
+ description TEXT,
+ business_license VARCHAR(100),
+ contact_name VARCHAR(100),
+ contact_phone VARCHAR(32),
+ contact_email VARCHAR(200),
+ address JSONB, -- 店铺地址信息
+ business_hours JSONB, -- 营业时间
+
+ -- 状态
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:关闭
+
+ -- 统计
+ product_count INTEGER DEFAULT 0,
+ order_count INTEGER DEFAULT 0,
+ rating_avg DECIMAL(3,2) DEFAULT 0.00,
+ rating_count INTEGER DEFAULT 0,
+
+ -- 认证信息
+ verified_at TIMESTAMP WITH TIME ZONE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_shop_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_shops IS '店铺信息表;
+
+-- =====================================================================================
+-- 5. 订单管理模块
+-- =====================================================================================
+
+-- 订单表
+CREATE TABLE public.ml_orders (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ order_no VARCHAR(50) UNIQUE NOT NULL,
+ user_id UUID NOT NULL REFERENCES public.ak_users(id),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+
+ -- 金额信息
+ product_amount DECIMAL(12,2) NOT NULL DEFAULT 0, -- 商品金额
+ discount_amount DECIMAL(12,2) DEFAULT 0, -- 优惠金额
+ shipping_fee DECIMAL(12,2) DEFAULT 0, -- 运费
+ total_amount DECIMAL(12,2) NOT NULL, -- 总金额
+ paid_amount DECIMAL(12,2) DEFAULT 0, -- 已付金额
+
+ -- 地址信息
+ shipping_address JSONB NOT NULL, -- 收货地址
+
+ -- 状态信息
+ order_status INTEGER DEFAULT 1, -- 1:待付款2:待发货3:待收货4:已完成5:已取消6:退款中 7:已退款
+ payment_status INTEGER DEFAULT 1, -- 1:未付款2:已付款3:部分退款4:全额退款
+ shipping_status INTEGER DEFAULT 1, -- 1:未发货2:已发货3:运输中4:已送达
+
+ -- 时间信息
+ paid_at TIMESTAMP WITH TIME ZONE,
+ shipped_at TIMESTAMP WITH TIME ZONE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- 其他信息
+ remark TEXT, -- 买家备注
+ merchant_memo TEXT, -- 商家备注
+ cancel_reason TEXT, -- 取消原因
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_order_status CHECK (order_status IN (1,2,3,4,5,6,7)),
+ CONSTRAINT chk_ml_payment_status CHECK (payment_status IN (1,2,3,4)),
+ CONSTRAINT chk_ml_shipping_status CHECK (shipping_status IN (1,2,3,4))
+);
+
+COMMENT ON TABLE public.ml_orders IS '订单表;
+
+-- 订单商品表
+CREATE TABLE public.ml_order_items (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID NOT NULL REFERENCES public.ml_orders(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id),
+ sku_id UUID REFERENCES public.ml_product_skus(id),
+ product_name VARCHAR(500) NOT NULL,
+ sku_name VARCHAR(500),
+ specifications JSONB DEFAULT '{}',
+ image_url TEXT,
+ price DECIMAL(12,2) NOT NULL,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ total_amount DECIMAL(12,2) NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_order_items IS '订单商品表;
+
+-- =====================================================================================
+-- 6. 购物车表
+-- =====================================================================================
+
+-- 购物车表
+CREATE TABLE public.ml_shopping_cart (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_id UUID REFERENCES public.ml_product_skus(id) ON DELETE CASCADE,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ selected BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, product_id, sku_id)
+);
+
+COMMENT ON TABLE public.ml_shopping_cart IS '购物车表';
+
+-- =====================================================================================
+-- 7. 营销管理模块
+-- =====================================================================================
+
+-- 优惠券模板表
+CREATE TABLE public.ml_coupon_templates (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID REFERENCES public.ak_users(id), -- NULL表示平台券
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ coupon_type INTEGER NOT NULL, -- 1:满减券2:折扣券3:免运费券
+ discount_type INTEGER NOT NULL, -- 1:固定金额 2:百分比
+ discount_value DECIMAL(12,2) NOT NULL, -- 优惠值
+ min_order_amount DECIMAL(12,2) DEFAULT 0, -- 最低订单金额
+ max_discount_amount DECIMAL(12,2), -- 最大优惠金额
+ total_quantity INTEGER, -- 总发放数量
+ per_user_limit INTEGER DEFAULT 1, -- 每用户限领数量
+ usage_limit INTEGER DEFAULT 1, -- 每张券使用次数限制
+
+ -- 适用范围
+ applicable_products JSONB DEFAULT '[]', -- 适用商品ID数组
+ applicable_categories JSONB DEFAULT '[]', -- 适用分类ID数组
+
+ -- 时间限制
+ start_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_time TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:已结束
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_coupon_type CHECK (coupon_type IN (1,2,3)),
+ CONSTRAINT chk_ml_discount_type CHECK (discount_type IN (1,2)),
+ CONSTRAINT chk_ml_coupon_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_coupon_templates IS '优惠券模板表';
+
+-- 用户优惠券表
+CREATE TABLE public.ml_user_coupons (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ template_id UUID NOT NULL REFERENCES public.ml_coupon_templates(id),
+ coupon_code VARCHAR(50) UNIQUE NOT NULL,
+ status INTEGER DEFAULT 1, -- 1:未使用2:已使用3:已过期
+ used_at TIMESTAMP WITH TIME ZONE,
+ order_id UUID REFERENCES public.ml_orders(id),
+ received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ expire_at TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ CONSTRAINT chk_ml_user_coupon_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_user_coupons IS '用户优惠券表';
+
+-- =====================================================================================
+-- 8. 配送管理表
+-- =====================================================================================
+
+-- 配送员信息表
+CREATE TABLE public.ml_delivery_drivers (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ real_name VARCHAR(100) NOT NULL,
+ id_card VARCHAR(32) NOT NULL,
+ driver_license VARCHAR(50),
+ vehicle_type INTEGER, -- 1:电动车2:摩托车3:汽车
+ vehicle_number VARCHAR(20),
+ service_areas JSONB DEFAULT '[]', -- 服务区域
+ work_status INTEGER DEFAULT 1, -- 1:在线 2:忙碌 3:离线
+ current_lat DECIMAL(10,7),
+ current_lng DECIMAL(10,7),
+ rating_avg DECIMAL(3,2) DEFAULT 0.00,
+ rating_count INTEGER DEFAULT 0,
+ order_count INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:离职
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_driver_vehicle_type CHECK (vehicle_type IN (1,2,3)),
+ CONSTRAINT chk_ml_driver_work_status CHECK (work_status IN (1,2,3)),
+ CONSTRAINT chk_ml_driver_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_delivery_drivers IS '配送员信息表;
+
+-- 配送任务表
+CREATE TABLE public.ml_delivery_tasks (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID UNIQUE NOT NULL REFERENCES public.ml_orders(id),
+ driver_id UUID REFERENCES public.ml_delivery_drivers(id),
+ pickup_address JSONB NOT NULL, -- 取货地址
+ delivery_address JSONB NOT NULL, -- 配送地址
+ distance DECIMAL(8,2), -- 配送距离km)
+ estimated_time INTEGER, -- 预计配送时间分钟)
+ delivery_fee DECIMAL(10,2) NOT NULL DEFAULT 0,
+
+ status INTEGER DEFAULT 1, -- 1:待接单2:已接单3:取货中4:配送中 5:已送达 6:配送失败
+
+ -- 时间记录
+ assigned_at TIMESTAMP WITH TIME ZONE,
+ picked_at TIMESTAMP WITH TIME ZONE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+
+ -- 其他信息
+ delivery_code VARCHAR(10), -- 取货中
+ remark TEXT,
+ failure_reason TEXT,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_delivery_status CHECK (status IN (1,2,3,4,5,6))
+);
+
+COMMENT ON TABLE public.ml_delivery_tasks IS '配送任务表';
+
+-- =====================================================================================
+-- 9. 评价管理模块
+-- =====================================================================================
+
+-- 商品评价表
+CREATE TABLE public.ml_product_reviews (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID NOT NULL REFERENCES public.ml_orders(id),
+ order_item_id UUID NOT NULL REFERENCES public.ml_order_items(id),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+
+ rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ content TEXT,
+ images JSONB DEFAULT '[]', -- 评价图片
+ is_anonymous BOOLEAN DEFAULT FALSE,
+
+ -- 商家回复
+ merchant_reply TEXT,
+ merchant_replied_at TIMESTAMP WITH TIME ZONE,
+
+ status INTEGER DEFAULT 1, -- 1:正常 2:已删除3:已隐藏
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_review_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_product_reviews IS '商品评价表;
+
+-- =====================================================================================
+-- 10. 用户行为模块
+-- =====================================================================================
+
+-- 用户收藏表
+CREATE TABLE public.ml_user_favorites (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ target_type INTEGER NOT NULL, -- 1:商品 2:店铺
+ target_id UUID NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, target_type, target_id),
+ CONSTRAINT chk_ml_favorite_type CHECK (target_type IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_user_favorites IS '用户收藏表;
+
+-- 用户浏览历史表
+CREATE TABLE public.ml_browse_history (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ browse_duration INTEGER DEFAULT 0, -- 浏览时长(秒)
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, product_id)
+);
+
+COMMENT ON TABLE public.ml_browse_history IS '用户浏览历史表;
+
+-- 搜索记录表
+CREATE TABLE public.ml_search_history (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ keyword VARCHAR(200) NOT NULL,
+ result_count INTEGER DEFAULT 0,
+ ip_address INET,
+ user_agent TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_search_history IS '搜索记录表;
+
+-- =====================================================================================
+-- 11. 系统配置表
+-- =====================================================================================
+
+-- 系统配置表
+CREATE TABLE public.ml_system_configs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ config_key VARCHAR(100) UNIQUE NOT NULL,
+ config_value JSONB,
+ description TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_system_configs IS '系统配置表;
+
+-- 地区表如果需要独立的地区管理)
+CREATE TABLE public.ml_regions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ parent_id UUID REFERENCES public.ml_regions(id),
+ name VARCHAR(100) NOT NULL,
+ code VARCHAR(20),
+ level INTEGER NOT NULL, -- 1:省份 2:城市 3:区县 4:街道
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_regions IS '地区表;
+
+-- =====================================================================================
+-- 12. 创建索引
+-- =====================================================================================
+
+-- 用户扩展表索引
+CREATE INDEX idx_ml_user_profiles_user_id ON public.ml_user_profiles(user_id);
+
+CREATE INDEX idx_ml_user_profiles_status ON public.ml_user_profiles(status);
+
+-- 分类表索引
+CREATE INDEX idx_ml_categories_cid ON public.ml_categories(cid);
+CREATE INDEX idx_ml_categories_parent ON public.ml_categories(parent_id);
+CREATE INDEX idx_ml_categories_slug ON public.ml_categories(slug);
+CREATE INDEX idx_ml_categories_level ON public.ml_categories(level, sort_order);
+
+-- 品牌表索引
+CREATE INDEX idx_ml_brands_cid ON public.ml_brands(cid);
+CREATE INDEX idx_ml_brands_name ON public.ml_brands(name);
+
+-- 地址表索引
+CREATE INDEX idx_ml_user_addresses_user_id ON public.ml_user_addresses(user_id);
+CREATE INDEX idx_ml_user_addresses_default ON public.ml_user_addresses(user_id, is_default);
+CREATE INDEX idx_ml_user_addresses_location ON public.ml_user_addresses(city, district);
+
+-- 商品表索引
+CREATE INDEX idx_ml_products_cid ON public.ml_products(cid);
+CREATE INDEX idx_ml_products_merchant ON public.ml_products(merchant_id, status);
+CREATE INDEX idx_ml_products_category ON public.ml_products(category_id, status);
+CREATE INDEX idx_ml_products_status ON public.ml_products(status, created_at DESC);
+CREATE INDEX idx_ml_products_featured ON public.ml_products(is_featured, status);
+CREATE INDEX idx_ml_products_price ON public.ml_products(base_price);
+CREATE INDEX idx_ml_products_rating ON public.ml_products(rating_avg DESC, rating_count DESC);
+CREATE INDEX idx_ml_products_sale_count ON public.ml_products(sale_count DESC);
+CREATE INDEX idx_ml_products_tags ON public.ml_products USING GIN(tags);
+CREATE INDEX idx_ml_products_slug ON public.ml_products(slug);
+
+-- 店铺表索引
+CREATE INDEX idx_ml_shops_cid ON public.ml_shops(cid);
+CREATE INDEX idx_ml_shops_merchant ON public.ml_shops(merchant_id);
+
+-- SKU表索引
+CREATE INDEX idx_ml_product_skus_product ON public.ml_product_skus(product_id);
+CREATE INDEX idx_ml_product_skus_code ON public.ml_product_skus(sku_code);
+
+-- 订单表索引
+CREATE INDEX idx_ml_orders_cid ON public.ml_orders(cid);
+CREATE INDEX idx_ml_orders_user ON public.ml_orders(user_id, created_at DESC);
+CREATE INDEX idx_ml_orders_merchant ON public.ml_orders(merchant_id, created_at DESC);
+CREATE INDEX idx_ml_orders_status ON public.ml_orders(order_status, created_at DESC);
+CREATE INDEX idx_ml_orders_no ON public.ml_orders(order_no);
+
+-- 订单商品表索引
+CREATE INDEX idx_ml_order_items_order ON public.ml_order_items(order_id);
+CREATE INDEX idx_ml_order_items_product ON public.ml_order_items(product_id);
+
+-- 购物车表索引
+CREATE INDEX idx_ml_shopping_cart_user ON public.ml_shopping_cart(user_id);
+
+-- 优惠券模板表索引
+CREATE INDEX idx_ml_coupon_templates_cid ON public.ml_coupon_templates(cid);
+CREATE INDEX idx_ml_coupon_templates_merchant ON public.ml_coupon_templates(merchant_id);
+
+-- 优惠券表索引
+CREATE INDEX idx_ml_user_coupons_user ON public.ml_user_coupons(user_id, status);
+CREATE INDEX idx_ml_user_coupons_code ON public.ml_user_coupons(coupon_code);
+
+-- 收藏表索引
+CREATE INDEX idx_ml_user_favorites_user ON public.ml_user_favorites(user_id, target_type);
+CREATE INDEX idx_ml_user_favorites_target ON public.ml_user_favorites(target_type, target_id);
+
+-- 浏览历史索引
+CREATE INDEX idx_ml_browse_history_user ON public.ml_browse_history(user_id, created_at DESC);
+CREATE INDEX idx_ml_browse_history_product ON public.ml_browse_history(product_id);
+
+-- =====================================================================================
+-- 13. 触发器函数
+-- =====================================================================================
+
+-- 自动更新 updated_at 字段的函数
+CREATE OR REPLACE FUNCTION public.update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 为需要的表创建 updated_at 触发器
+CREATE TRIGGER trigger_ml_user_profiles_updated_at
+ BEFORE UPDATE ON public.ml_user_profiles
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_user_addresses_updated_at
+ BEFORE UPDATE ON public.ml_user_addresses
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_products_updated_at
+ BEFORE UPDATE ON public.ml_products
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_product_skus_updated_at
+ BEFORE UPDATE ON public.ml_product_skus
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_shops_updated_at
+ BEFORE UPDATE ON public.ml_shops
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_orders_updated_at
+ BEFORE UPDATE ON public.ml_orders
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_shopping_cart_updated_at
+ BEFORE UPDATE ON public.ml_shopping_cart
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- 确保每个用户只有一个默认地址的触发器
+CREATE OR REPLACE FUNCTION public.ensure_single_default_address()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.is_default = TRUE THEN
+ UPDATE public.ml_user_addresses
+ SET is_default = FALSE
+ WHERE user_id = NEW.user_id AND id != NEW.id;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_single_default_address
+ BEFORE INSERT OR UPDATE ON public.ml_user_addresses
+ FOR EACH ROW EXECUTE FUNCTION public.ensure_single_default_address();
+
+-- 商品库存更新触发器
+CREATE OR REPLACE FUNCTION public.update_product_stock()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- 更新商品总库存
+ UPDATE public.ml_products
+ SET
+ total_stock = (
+ SELECT COALESCE(SUM(stock), 0)
+ FROM public.ml_product_skus
+ WHERE product_id = NEW.product_id AND status = 1
+ ),
+ available_stock = (
+ SELECT COALESCE(SUM(stock), 0)
+ FROM public.ml_product_skus
+ WHERE product_id = NEW.product_id AND status = 1
+ )
+ WHERE id = NEW.product_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_update_product_stock
+ AFTER INSERT OR UPDATE OR DELETE ON public.ml_product_skus
+ FOR EACH ROW EXECUTE FUNCTION public.update_product_stock();
+
+-- 订单状态变更时的处理
+CREATE OR REPLACE FUNCTION public.handle_order_status_change()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- 如果订单状态变为已付款
+ IF NEW.order_status = 2 AND OLD.order_status = 1 THEN
+ NEW.paid_at = NOW();
+ END IF;
+
+ -- 如果订单状态变为已发货
+ IF NEW.order_status = 3 AND OLD.order_status = 2 THEN
+ NEW.shipped_at = NOW();
+ END IF;
+
+ -- 如果订单状态变为已完成
+ IF NEW.order_status = 4 AND OLD.order_status = 3 THEN
+ NEW.delivered_at = NOW();
+ NEW.completed_at = NOW();
+
+ -- 更新商品销量
+ UPDATE public.ml_products
+ SET sale_count = sale_count + (
+ SELECT SUM(quantity)
+ FROM public.ml_order_items
+ WHERE order_id = NEW.id
+ )
+ WHERE id IN (
+ SELECT product_id
+ FROM public.ml_order_items
+ WHERE order_id = NEW.id
+ );
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_order_status_change
+ BEFORE UPDATE ON public.ml_orders
+ FOR EACH ROW EXECUTE FUNCTION public.handle_order_status_change();
+
+-- =====================================================================================
+-- 14. 实用函数
+-- =====================================================================================
+
+-- 生成订单号的函数
+CREATE OR REPLACE FUNCTION public.generate_order_no()
+RETURNS TEXT AS $$
+DECLARE
+ order_no TEXT;
+BEGIN
+ order_no := 'ML' || TO_CHAR(NOW(), 'YYYYMMDD') || LPAD(NEXTVAL('ml_order_seq')::TEXT, 6, '0');
+ RETURN order_no;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 创建订单序列
+CREATE SEQUENCE IF NOT EXISTS public.ml_order_seq START 1;
+
+-- 生成优惠券码的函数
+CREATE OR REPLACE FUNCTION public.generate_coupon_code()
+RETURNS TEXT AS $$
+DECLARE
+ code TEXT;
+ chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ result TEXT := '';
+ i INTEGER;
+BEGIN
+ FOR i IN 1..8 LOOP
+ result := result || substr(chars, (random() * length(chars))::integer + 1, 1);
+ END LOOP;
+ RETURN 'CP' || result;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 获取用户默认地址
+CREATE OR REPLACE FUNCTION public.get_user_default_address(p_user_id UUID)
+RETURNS TABLE (
+ id UUID,
+ receiver_name VARCHAR,
+ receiver_phone VARCHAR,
+ full_address TEXT,
+ latitude DECIMAL,
+ longitude DECIMAL
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ a.id,
+ a.receiver_name,
+ a.receiver_phone,
+ (a.province || ' ' || a.city || ' ' || a.district || ' ' || a.address_detail) as full_address,
+ a.latitude,
+ a.longitude
+ FROM public.ml_user_addresses a
+ WHERE a.user_id = p_user_id AND a.is_default = TRUE AND a.status = 1
+ LIMIT 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 检查用户是否为认证商家
+CREATE OR REPLACE FUNCTION public.is_verified_merchant(p_user_id UUID)
+RETURNS BOOLEAN AS $$
+DECLARE
+ result BOOLEAN := FALSE;
+BEGIN
+ SELECT (u.role = 'merchant' AND p.verification_status = 1) INTO result
+ FROM public.ml_user_profiles p
+ JOIN public.ak_users u ON p.user_id = u.id
+ WHERE p.user_id = p_user_id;
+
+ RETURN COALESCE(result, FALSE);
+END;
+$$ LANGUAGE plpgsql;
+
+-- 计算购物车总金额
+CREATE OR REPLACE FUNCTION public.calculate_cart_total(p_user_id UUID)
+RETURNS DECIMAL AS $$
+DECLARE
+ total_amount DECIMAL := 0;
+BEGIN
+ SELECT COALESCE(SUM(s.price * c.quantity), 0) INTO total_amount
+ FROM public.ml_shopping_cart c
+ LEFT JOIN public.ml_product_skus s ON c.sku_id = s.id
+ LEFT JOIN public.ml_products p ON c.product_id = p.id
+ WHERE c.user_id = p_user_id
+ AND c.selected = TRUE
+ AND p.status = 1
+ AND (s.id IS NULL OR s.status = 1);
+
+ RETURN total_amount;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 获取商品可用库存
+CREATE OR REPLACE FUNCTION public.get_product_available_stock(p_product_id UUID, p_sku_id UUID DEFAULT NULL)
+RETURNS INTEGER AS $$
+DECLARE
+ stock_count INTEGER := 0;
+BEGIN
+ IF p_sku_id IS NOT NULL THEN
+ -- 获取特定SKU库存
+ SELECT COALESCE(stock, 0) INTO stock_count
+ FROM public.ml_product_skus
+ WHERE id = p_sku_id AND product_id = p_product_id AND status = 1;
+ ELSE
+ -- 获取商品总库存
+ SELECT COALESCE(available_stock, 0) INTO stock_count
+ FROM public.ml_products
+ WHERE id = p_product_id AND status = 1;
+ END IF;
+
+ RETURN stock_count;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =====================================================================================
+-- 15. 创建视图
+-- =====================================================================================
+
+-- 商城用户完整信息视图
+CREATE OR REPLACE VIEW public.ml_users_view AS
+SELECT
+ u.id,
+ u.username,
+ u.email,
+ u.phone,
+ u.avatar_url,
+ u.gender,
+ u.birthday,
+ u.bio,
+ u.created_at as user_created_at,
+ u.updated_at as user_updated_at,
+ u.role,
+ p.status,
+ p.real_name,
+ p.credit_score,
+ p.verification_status,
+ p.created_at as profile_created_at,
+ p.updated_at as profile_updated_at,
+ CASE
+ WHEN u.role = 'customer' THEN '消费者'
+ WHEN u.role = 'merchant' THEN '商家'
+ WHEN u.role = 'delivery' THEN '配送员'
+ WHEN u.role = 'service' THEN '客服'
+ WHEN u.role = 'admin' THEN '管理员'
+ ELSE '未知'
+ END as role_name
+FROM public.ak_users u
+LEFT JOIN public.ml_user_profiles p ON u.id = p.user_id;
+
+COMMENT ON VIEW public.ml_users_view IS '商城用户完整信息视图';
+
+-- 商品详情视图
+CREATE OR REPLACE VIEW public.ml_products_detail_view AS
+SELECT
+ p.*,
+ c.cid as category_cid,
+ c.name as category_name,
+ c.path as category_path,
+ b.cid as brand_cid,
+ b.name as brand_name,
+ s.cid as shop_cid,
+ s.shop_name,
+ u.username as merchant_name,
+ CASE
+ WHEN p.status = 1 THEN '上架'
+ WHEN p.status = 2 THEN '下架'
+ WHEN p.status = 3 THEN '草稿'
+ WHEN p.status = 4 THEN '删除'
+ ELSE '未知'
+ END as status_name
+FROM public.ml_products p
+LEFT JOIN public.ml_categories c ON p.category_id = c.id
+LEFT JOIN public.ml_brands b ON p.brand_id = b.id
+LEFT JOIN public.ml_shops s ON p.merchant_id = s.merchant_id
+LEFT JOIN public.ak_users u ON p.merchant_id = u.id;
+
+COMMENT ON VIEW public.ml_products_detail_view IS '商品详情视图';
+
+-- 订单详情视图
+CREATE OR REPLACE VIEW public.ml_orders_detail_view AS
+SELECT
+ o.*,
+ u.username as customer_name,
+ u.phone as customer_phone,
+ m.username as merchant_name,
+ s.shop_name,
+ CASE
+ WHEN o.order_status = 1 THEN '待付款
+ WHEN o.order_status = 2 THEN '待发货
+ WHEN o.order_status = 3 THEN '待收货
+ WHEN o.order_status = 4 THEN '已完成
+ WHEN o.order_status = 5 THEN '已取消
+ WHEN o.order_status = 6 THEN '退款中'
+ WHEN o.order_status = 7 THEN '已退款
+ ELSE '未知'
+ END as order_status_name,
+ CASE
+ WHEN o.payment_status = 1 THEN '未付款
+ WHEN o.payment_status = 2 THEN '已付款
+ WHEN o.payment_status = 3 THEN '部分退款
+ WHEN o.payment_status = 4 THEN '全额退款
+ ELSE '未知'
+ END as payment_status_name
+FROM public.ml_orders o
+LEFT JOIN public.ak_users u ON o.user_id = u.id
+LEFT JOIN public.ak_users m ON o.merchant_id = m.id
+LEFT JOIN public.ml_shops s ON o.merchant_id = s.merchant_id;
+
+COMMENT ON VIEW public.ml_orders_detail_view IS '订单详情视图';
+
+-- =====================================================================================
+-- 16. RLS (Row Level Security) 策略
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_user_profiles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_addresses ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_shopping_cart ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_favorites ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_browse_history ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_coupons ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_orders ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_products ENABLE ROW LEVEL SECURITY;
+
+-- 用户只能访问自己的数据
+CREATE POLICY ml_user_profiles_select_policy ON public.ml_user_profiles
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_insert_policy ON public.ml_user_profiles
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_update_policy ON public.ml_user_profiles
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_delete_policy ON public.ml_user_profiles
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_select_policy ON public.ml_user_addresses
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_insert_policy ON public.ml_user_addresses
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_update_policy ON public.ml_user_addresses
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_delete_policy ON public.ml_user_addresses
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_select_policy ON public.ml_shopping_cart
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_insert_policy ON public.ml_shopping_cart
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_update_policy ON public.ml_shopping_cart
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_delete_policy ON public.ml_shopping_cart
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_select_policy ON public.ml_user_favorites
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_insert_policy ON public.ml_user_favorites
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_update_policy ON public.ml_user_favorites
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_delete_policy ON public.ml_user_favorites
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_select_policy ON public.ml_browse_history
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_insert_policy ON public.ml_browse_history
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_update_policy ON public.ml_browse_history
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_delete_policy ON public.ml_browse_history
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_select_policy ON public.ml_user_coupons
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_insert_policy ON public.ml_user_coupons
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_update_policy ON public.ml_user_coupons
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_delete_policy ON public.ml_user_coupons
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+-- 订单策略:用户可以查看自己的订单,商家可以查看自己店铺的订单
+CREATE POLICY ml_orders_select_policy ON public.ml_orders
+ FOR SELECT USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_insert_policy ON public.ml_orders
+ FOR INSERT WITH CHECK (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_update_policy ON public.ml_orders
+ FOR UPDATE USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_delete_policy ON public.ml_orders
+ FOR DELETE USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+-- 商品策略:所有人可以查看上架商品,商家只能管理自己的商品
+CREATE POLICY ml_products_select_policy ON public.ml_products
+ FOR SELECT USING (status = 1);
+
+CREATE POLICY ml_products_insert_policy ON public.ml_products
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+CREATE POLICY ml_products_update_policy ON public.ml_products
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+CREATE POLICY ml_products_delete_policy ON public.ml_products
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+-- =====================================================================================
+-- 17. 初始化数据
+-- =====================================================================================
+
+-- 插入系统配置
+INSERT INTO public.ml_system_configs (config_key, config_value, description) VALUES
+('shipping_fee', '{"default": 10, "free_threshold": 88}', '配送费配置'),
+('platform_commission', '{"rate": 0.05}', '平台佣金配置'),
+('coupon_settings', '{"max_per_user": 10}', '优惠券设置),
+('order_auto_confirm_days', '7', '订单自动确认天数');
+
+-- 插入默认分类
+INSERT INTO public.ml_categories (id, name, slug, level, path) VALUES
+(uuid_generate_v4(), '数码电器', 'digital', 1, ARRAY['数码电器']),
+(uuid_generate_v4(), '服装鞋帽', 'fashion', 1, ARRAY['服装鞋帽']),
+(uuid_generate_v4(), '家居用品', 'home', 1, ARRAY['家居用品']),
+(uuid_generate_v4(), '食品饮料', 'food', 1, ARRAY['食品饮料']),
+(uuid_generate_v4(), '美妆护肤', 'beauty', 1, ARRAY['美妆护肤']);
+
+-- 为现有 ak_users 用户创建默认商城档案
+INSERT INTO public.ml_user_profiles (user_id, status)
+SELECT
+ id,
+ 1 -- 默认状态正常
+FROM public.ak_users
+WHERE id NOT IN (SELECT user_id FROM public.ml_user_profiles WHERE user_id IS NOT NULL);
+
+-- =====================================================================================
+-- 18. 完成提示
+-- =====================================================================================
+
+DO $$
+BEGIN
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '商城数据库创建完成!';
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '已创建表数量: 20+ 张表';
+ RAISE NOTICE '已创建索引 30+ 个索引;
+ RAISE NOTICE '已创建触发器: 8 个触发器';
+ RAISE NOTICE '已创建函数 10+ 个函数;
+ RAISE NOTICE '已创建视图 3 个视图;
+ RAISE NOTICE '已设置RLS策略: 多个策略';
+ RAISE NOTICE '已为现有用户创建默认档案';
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '表名前缀: ml_';
+ RAISE NOTICE '复用主表 ak_users';
+ RAISE NOTICE '兼容: Supabase';
+ RAISE NOTICE '=======================================================';
+END $$;
+
+-- =====================================================================================
+-- SEO 优化相关函数
+-- =====================================================================================
+
+-- 根据 cid 获取商品信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_product_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ slug VARCHAR,
+ description TEXT,
+ main_image_url TEXT,
+ base_price DECIMAL,
+ rating_avg DECIMAL,
+ sale_count INTEGER,
+ category_name VARCHAR,
+ brand_name VARCHAR,
+ shop_name VARCHAR
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ p.id,
+ p.cid,
+ p.name,
+ p.slug,
+ p.description,
+ p.main_image_url,
+ p.base_price,
+ p.rating_avg,
+ p.sale_count,
+ c.name as category_name,
+ b.name as brand_name,
+ s.shop_name
+ FROM public.ml_products p
+ LEFT JOIN public.ml_categories c ON p.category_id = c.id
+ LEFT JOIN public.ml_brands b ON p.brand_id = b.id
+ LEFT JOIN public.ml_shops s ON p.merchant_id = s.merchant_id
+ WHERE p.cid = p_cid AND p.status = 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取分类信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_category_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ slug VARCHAR,
+ description TEXT,
+ icon_url TEXT,
+ path TEXT[]
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ c.id,
+ c.cid,
+ c.name,
+ c.slug,
+ c.description,
+ c.icon_url,
+ c.path
+ FROM public.ml_categories c
+ WHERE c.cid = p_cid AND c.is_active = TRUE;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取品牌信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_brand_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ logo_url TEXT,
+ description TEXT
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ b.id,
+ b.cid,
+ b.name,
+ b.logo_url,
+ b.description
+ FROM public.ml_brands b
+ WHERE b.cid = p_cid AND b.is_active = TRUE;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取店铺信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_shop_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ shop_name VARCHAR,
+ description TEXT,
+ shop_logo TEXT,
+ rating_avg DECIMAL,
+ product_count INTEGER
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ s.id,
+ s.cid,
+ s.shop_name,
+ s.description,
+ s.shop_logo,
+ s.rating_avg,
+ s.product_count
+ FROM public.ml_shops s
+ WHERE s.cid = p_cid AND s.status = 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 生成 SEO 友好的 URL 路径
+CREATE OR REPLACE FUNCTION public.generate_seo_url(
+ p_type VARCHAR, -- 'product', 'category', 'brand', 'shop'
+ p_cid INTEGER,
+ p_slug VARCHAR DEFAULT NULL
+)
+RETURNS TEXT AS $$
+DECLARE
+ url_path TEXT;
+BEGIN
+ CASE p_type
+ WHEN 'product' THEN
+ url_path := '/product/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'category' THEN
+ url_path := '/category/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'brand' THEN
+ url_path := '/brand/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'shop' THEN
+ url_path := '/shop/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ ELSE
+ url_path := '/' || p_type || '/' || p_cid;
+ END CASE;
+
+ RETURN url_path;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 批量更新 slug 字段(用于现有数据)
+CREATE OR REPLACE FUNCTION public.update_seo_slugs()
+RETURNS VOID AS $$
+BEGIN
+ -- 更新商品 slug
+ UPDATE public.ml_products
+ SET slug = LOWER(REGEXP_REPLACE(name, '[^a-zA-Z0-9\u4e00-\u9fa5]+', '-', 'g'))
+ WHERE slug IS NULL OR slug = '';
+
+ -- 更新分类 slug
+ UPDATE public.ml_categories
+ SET slug = LOWER(REGEXP_REPLACE(name, '[^a-zA-Z0-9\u4e00-\u9fa5]+', '-', 'g'))
+ WHERE slug IS NULL OR slug = '';
+
+ RAISE NOTICE 'SEO slugs updated successfully';
+END;
+$$ LANGUAGE plpgsql;
+
+```
+
+### 2) 扩展模块 Schema(docs/sql/10_schema/)
+```sql
+-- =====================================================================================
+-- Migration: 全量软删除 (Soft Delete) 基础设施补齐
+-- 位置:docs/sql/10_schema/99_soft_delete_migration_v1.sql
+-- 对象类型:ALTER TABLE
+-- 说明:为所有核心业务表补齐 deleted_at, deleted_by, restored_at, restored_by 字段
+-- 涵盖:权限、配置、内容、装修、物流、分销、财务、客服、营销、商品、用户、订单
+-- =====================================================================================
+
+DO $$
+DECLARE
+ -- 需补齐软删除字段的业务表全量清单
+ t_names TEXT[] := ARRAY[
+ -- 1. 系统与权限 (Auth/System)
+ 'ak_roles', 'ak_permissions', 'ak_admin_roles', 'ak_role_permissions', 'ml_system_configs',
+
+ -- 2. 内容与装修 (CMS/Decoration)
+ 'ml_articles', 'ml_article_categories', 'ak_diy_pages',
+
+ -- 3. 物流资源 (Delivery)
+ 'ml_delivery_staff', 'ml_delivery_stations', 'ak_shipping_templates',
+
+ -- 4. 分销体系 (Distribution)
+ 'ak_distribution_agents', 'ak_distribution_divisions',
+ 'ak_distribution_agent_applications', 'ak_distribution_division_applications',
+ 'ak_promoter_relations', 'ak_commission_logs', 'ak_distribution_level', 'ak_distribution_config',
+
+ -- 5. 财务管理 (Finance)
+ 'ml_extract', 'ml_invoices', 'ml_user_bill', 'ml_user_recharge',
+
+ -- 6. 客服系统 (Kefu)
+ 'ml_kefu_accounts', 'ml_kefu_words', 'ml_kefu_word_categories', 'ml_kefu_auto_replies', 'ml_kefu_feedbacks',
+
+ -- 7. 营销活动 (Marketing)
+ 'ak_advanced_marketing', 'ak_bargain_groupbuy', 'ak_live_products', 'ak_lottery_live',
+ 'ak_marketing_checkin_configs', 'ak_marketing_newcomer_config', 'ak_marketing_signin_logs',
+ 'ak_member_management', 'ak_recharge_management', 'ak_signin_configs',
+
+ -- 8. 商品中心 (Product)
+ 'ml_products', 'ml_product_skus', 'ml_categories',
+ 'ak_product_labels', 'ak_product_member_prices', 'ak_product_protections', 'ak_product_templates',
+
+ -- 9. 用户管理 (User)
+ 'ak_user_labels', 'ak_user_groups', 'ak_user_levels', 'ak_users',
+
+ -- 10. 订单中心 (Order)
+ 'ml_orders'
+ ];
+ t_name TEXT;
+BEGIN
+ FOREACH t_name IN ARRAY t_names LOOP
+ -- 检查表是否存在
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = t_name) THEN
+
+ -- 1. 增加 deleted_at 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'deleted_at') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL', t_name);
+ END IF;
+
+ -- 2. 增加 deleted_by 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'deleted_by') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL', t_name);
+ END IF;
+
+ -- 3. 增加 restored_at 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'restored_at') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN restored_at TIMESTAMPTZ DEFAULT NULL', t_name);
+ END IF;
+
+ -- 4. 增加 restored_by 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = t_name AND column_name = 'restored_by') THEN
+ EXECUTE format('ALTER TABLE public.%I ADD COLUMN restored_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL', t_name);
+ END IF;
+
+ -- 5. 建立软删除索引
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (deleted_at) WHERE deleted_at IS NULL', 'idx_' || t_name || '_soft_delete', t_name);
+
+ END IF;
+ END LOOP;
+END $$;
+-- =====================================================================================
+-- Schema: 系统配置表
+-- 位置:docs/sql/10_schema/admin/ml_system_configs_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:统一存储系统、应用、维护等模块的 Key-Value 配置项
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_system_configs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ config_key TEXT UNIQUE NOT NULL,
+ config_value JSONB NOT NULL DEFAULT '{}'::jsonb,
+ description TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_system_configs_key ON public.ml_system_configs (config_key);
+
+-- 注释
+COMMENT ON TABLE public.ml_system_configs IS '系统全局配置表';
+COMMENT ON COLUMN public.ml_system_configs.config_key IS '配置唯一标识键';
+COMMENT ON COLUMN public.ml_system_configs.config_value IS '配置内容 (JSONB)';
+-- =====================================================================================
+-- Migration: Auth 安全约束增强
+-- 位置:docs/sql/10_schema/auth/ak_auth_security_constraints_v1.sql
+-- 对象类型:ALTER TABLE / CONSTRAINT
+-- 说明:增强 ak_users 与 auth.users 的关联安全性,防止孤儿数据
+-- =====================================================================================
+
+-- 1. 确保 ak_users.auth_id 存在外键约束指向 auth.users
+-- 注意:Supabase 的 auth.users 表在 auth schema 下,需要确保权限正确
+DO $$
+BEGIN
+ -- 检查是否已存在外键约束
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_users_auth_id'
+ AND table_name = 'ak_users'
+ ) THEN
+ -- 添加外键约束,当 auth.users 被删除时自动删除对应的 profile
+ ALTER TABLE public.ak_users
+ ADD CONSTRAINT fk_ak_users_auth_id
+ FOREIGN KEY (auth_id) REFERENCES auth.users(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+-- 2. 为 auth_id 建立唯一索引,确保一个 auth 用户只有一个 profile
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ak_users_auth_id_unique
+ON public.ak_users(auth_id);
+
+-- 3. 为 role 字段建立索引,加速权限查询
+CREATE INDEX IF NOT EXISTS idx_ak_users_role
+ON public.ak_users(role);
+
+-- 4. 添加检查约束,确保 role 字段只能是有效值
+ALTER TABLE public.ak_users
+DROP CONSTRAINT IF EXISTS chk_ak_users_role_valid;
+
+ALTER TABLE public.ak_users
+ADD CONSTRAINT chk_ak_users_role_valid
+CHECK (role IN ('user', 'admin', 'staff', 'agent', 'kefu') OR role IS NULL);
+
+-- 5. 为 ak_admin_roles 添加约束确保关联有效性
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_admin_roles_admin_id'
+ AND table_name = 'ak_admin_roles'
+ ) THEN
+ ALTER TABLE public.ak_admin_roles
+ ADD CONSTRAINT fk_ak_admin_roles_admin_id
+ FOREIGN KEY (admin_id) REFERENCES public.ak_users(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+-- 6. 为 ak_role_permissions 添加约束确保关联有效性
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_role_permissions_role_id'
+ AND table_name = 'ak_role_permissions'
+ ) THEN
+ ALTER TABLE public.ak_role_permissions
+ ADD CONSTRAINT fk_ak_role_permissions_role_id
+ FOREIGN KEY (role_id) REFERENCES public.ak_roles(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'fk_ak_role_permissions_permission_id'
+ AND table_name = 'ak_role_permissions'
+ ) THEN
+ ALTER TABLE public.ak_role_permissions
+ ADD CONSTRAINT fk_ak_role_permissions_permission_id
+ FOREIGN KEY (permission_id) REFERENCES public.ak_permissions(id)
+ ON DELETE CASCADE;
+ END IF;
+END $$;-- =====================================================================================
+-- Schema: 内容管理模块核心表
+-- 位置:docs/sql/10_schema/cms/ml_cms_tables_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:包含文章分类及文章主表定义
+-- =====================================================================================
+
+-- 1. 文章分类表
+CREATE TABLE IF NOT EXISTS public.ml_article_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ icon TEXT NULL,
+ sort INTEGER NOT NULL DEFAULT 0,
+ status SMALLINT NOT NULL DEFAULT 1, -- 1: 启用, 0: 禁用
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 唯一性约束
+CREATE UNIQUE INDEX IF NOT EXISTS ml_article_categories_name_uniq ON public.ml_article_categories (name);
+
+-- 2. 文章主表
+CREATE TABLE IF NOT EXISTS public.ml_articles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category_id UUID NOT NULL REFERENCES public.ml_article_categories(id),
+
+ title TEXT NOT NULL,
+ author TEXT NULL,
+ image TEXT NULL, -- 文章封面图
+ description TEXT NULL, -- 文章简介
+ content TEXT NOT NULL, -- 文章内容 (富文本)
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0: 未发布, 1: 已发布
+ views INTEGER NOT NULL DEFAULT 0, -- 浏览量
+
+ is_banner BOOLEAN NOT NULL DEFAULT FALSE, -- 是否展示在 banner
+ is_hot BOOLEAN NOT NULL DEFAULT FALSE, -- 是否热门
+
+ linked_product_id UUID NULL, -- 关联商品ID (可选)
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ml_articles_category_id_idx ON public.ml_articles (category_id);
+CREATE INDEX IF NOT EXISTS ml_articles_status_idx ON public.ml_articles (status);
+CREATE INDEX IF NOT EXISTS ml_articles_created_at_idx ON public.ml_articles (created_at DESC);
+-- =====================================================================================
+-- Schema: 装修模块 - DIY 页面配置表
+-- 位置:docs/sql/10_schema/decoration/ak_diy_pages_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:存储首页、专题页及个人中心的 DIY 布局 JSON 配置
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_diy_pages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ type TEXT NOT NULL, -- home: 首页, topic: 专题页, user: 个人中心
+
+ config JSONB NOT NULL DEFAULT '{}'::jsonb, -- 核心布局配置 (组件列表及参数)
+
+ is_home BOOLEAN NOT NULL DEFAULT FALSE, -- 是否为生效首页
+ is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_diy_pages_type ON public.ak_diy_pages (type);
+CREATE INDEX IF NOT EXISTS idx_diy_pages_is_home ON public.ak_diy_pages (is_home) WHERE is_home = TRUE;
+
+-- 注释
+COMMENT ON TABLE public.ak_diy_pages IS 'DIY 页面装修配置表';
+COMMENT ON COLUMN public.ak_diy_pages.type IS '页面类型: home(首页), topic(专题), user(个人中心)';
+COMMENT ON COLUMN public.ak_diy_pages.config IS 'DIY 布局配置 JSON';
+-- =====================================================================================
+-- Schema: 物流设置 (Delivery) 核心表
+-- 位置:docs/sql/10_schema/delivery/ak_delivery_system_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:包含配送员管理表、提货点/核销点管理表
+-- =====================================================================================
+
+-- 1. 配送员管理表
+CREATE TABLE IF NOT EXISTS public.ml_delivery_staff (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID REFERENCES public.ak_users(id) ON DELETE SET NULL, -- 关联用户(可选)
+
+ nickname TEXT NOT NULL, -- 配送员名称
+ avatar TEXT, -- 头像
+ phone TEXT NOT NULL, -- 手机号
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-启用, 0-禁用
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 提货点/核销点管理表
+CREATE TABLE IF NOT EXISTS public.ml_delivery_stations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL, -- 提货点名称
+ phone TEXT NOT NULL, -- 联系电话
+ address TEXT NOT NULL, -- 详细地址
+
+ image TEXT, -- 门店图片
+ lng NUMERIC(10,7), -- 经度
+ lat NUMERIC(10,7), -- 纬度
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-显示, 0-隐藏
+ sort_order INTEGER DEFAULT 0,
+
+ business_hours JSONB, -- 营业时间 (如: {"start": "09:00", "end": "21:00"})
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_delivery_staff_phone ON public.ml_delivery_staff(phone);
+CREATE INDEX IF NOT EXISTS idx_delivery_stations_status ON public.ml_delivery_stations(status);
+
+-- 注释
+COMMENT ON TABLE public.ml_delivery_staff IS '配送员信息表';
+COMMENT ON TABLE public.ml_delivery_stations IS '提货点/核销点信息表';
+-- =====================================================================================
+-- Schema: 医养执行端 Delivery 基础表升级
+-- 位置:docs/sql/10_schema/delivery/ak_delivery_system_v2.sql
+-- 对象类型:ALTER TABLE / INDEX / TRIGGER
+-- 版本:v2
+-- 说明:在 v1 基础上最小升级为“医养上门服务执行端”可用模型。
+-- 补齐 uid 唯一索引、软删除、机构关联、staff_no、在线状态、资质状态。
+-- =====================================================================================
+
+-- 0. 通用 updated_at 触发器
+CREATE OR REPLACE FUNCTION public.tg_set_updated_at()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$;
+
+-- 1. 配送/执行人员表升级
+ALTER TABLE public.ml_delivery_staff
+ ADD COLUMN IF NOT EXISTS station_id UUID REFERENCES public.ml_delivery_stations(id) ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS staff_no TEXT,
+ ADD COLUMN IF NOT EXISTS online_status TEXT NOT NULL DEFAULT 'resting',
+ ADD COLUMN IF NOT EXISTS certificate_status TEXT NOT NULL DEFAULT 'pending',
+ ADD COLUMN IF NOT EXISTS certificate_expire_at DATE,
+ ADD COLUMN IF NOT EXISTS service_area TEXT NOT NULL DEFAULT '',
+ ADD COLUMN IF NOT EXISTS skills JSONB NOT NULL DEFAULT '[]'::jsonb,
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL;
+
+ALTER TABLE public.ml_delivery_staff
+ DROP CONSTRAINT IF EXISTS chk_ml_delivery_staff_online_status;
+ALTER TABLE public.ml_delivery_staff
+ ADD CONSTRAINT chk_ml_delivery_staff_online_status
+ CHECK (online_status IN ('online', 'resting', 'busy'));
+
+ALTER TABLE public.ml_delivery_staff
+ DROP CONSTRAINT IF EXISTS chk_ml_delivery_staff_certificate_status;
+ALTER TABLE public.ml_delivery_staff
+ ADD CONSTRAINT chk_ml_delivery_staff_certificate_status
+ CHECK (certificate_status IN ('valid', 'expired', 'pending'));
+
+-- 2. 站点/机构表升级
+ALTER TABLE public.ml_delivery_stations
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS deleted_by UUID REFERENCES public.ak_users(id) ON DELETE SET NULL;
+
+-- 3. 索引
+CREATE UNIQUE INDEX IF NOT EXISTS uq_ml_delivery_staff_uid_active
+ ON public.ml_delivery_staff(uid)
+ WHERE uid IS NOT NULL AND deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_ml_delivery_staff_staff_no_active
+ ON public.ml_delivery_staff(staff_no)
+ WHERE staff_no IS NOT NULL AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_station_id
+ ON public.ml_delivery_staff(station_id)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_deleted_at
+ ON public.ml_delivery_staff(deleted_at);
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_status_active
+ ON public.ml_delivery_staff(status, is_active)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_stations_deleted_at
+ ON public.ml_delivery_stations(deleted_at);
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_stations_status_active
+ ON public.ml_delivery_stations(status)
+ WHERE deleted_at IS NULL;
+
+-- 4. 自动维护 updated_at
+DROP TRIGGER IF EXISTS trg_ml_delivery_staff_set_updated_at ON public.ml_delivery_staff;
+CREATE TRIGGER trg_ml_delivery_staff_set_updated_at
+BEFORE UPDATE ON public.ml_delivery_staff
+FOR EACH ROW
+EXECUTE FUNCTION public.tg_set_updated_at();
+
+DROP TRIGGER IF EXISTS trg_ml_delivery_stations_set_updated_at ON public.ml_delivery_stations;
+CREATE TRIGGER trg_ml_delivery_stations_set_updated_at
+BEFORE UPDATE ON public.ml_delivery_stations
+FOR EACH ROW
+EXECUTE FUNCTION public.tg_set_updated_at();
+
+-- 5. 注释
+COMMENT ON COLUMN public.ml_delivery_staff.station_id IS '所属机构/服务站点 ID';
+COMMENT ON COLUMN public.ml_delivery_staff.staff_no IS '服务人员编号';
+COMMENT ON COLUMN public.ml_delivery_staff.online_status IS '在线状态:online/resting/busy';
+COMMENT ON COLUMN public.ml_delivery_staff.certificate_status IS '资质状态:valid/expired/pending';
+COMMENT ON COLUMN public.ml_delivery_staff.certificate_expire_at IS '主要资质到期日期';
+COMMENT ON COLUMN public.ml_delivery_staff.service_area IS '服务区域描述';
+COMMENT ON COLUMN public.ml_delivery_staff.skills IS '技能标签 JSON 数组';
+COMMENT ON COLUMN public.ml_delivery_staff.deleted_at IS '软删除时间';
+COMMENT ON COLUMN public.ml_delivery_staff.deleted_by IS '软删除操作人';
+COMMENT ON COLUMN public.ml_delivery_stations.deleted_at IS '软删除时间';
+COMMENT ON COLUMN public.ml_delivery_stations.deleted_by IS '软删除操作人';
+-- 佣金流水表:记录每一笔佣金变动(冻结/可用/已提现/取消)
+CREATE TABLE IF NOT EXISTS public.ak_commission_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- 佣金获得者
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ -- 佣金来源用户(下单用户/被推广用户,可为空)
+ source_uid UUID REFERENCES public.ak_users(id) ON DELETE SET NULL,
+
+ -- 关联订单
+ order_id UUID REFERENCES public.ml_orders(id) ON DELETE SET NULL,
+ order_no VARCHAR(50),
+
+ -- 金额与状态
+ amount DECIMAL(12,2) NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'frozen', -- frozen/available/withdrawn/canceled
+
+ -- 冻结到期时间(用于解冻逻辑)
+ frozen_until TIMESTAMPTZ,
+
+ remark TEXT,
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_ak_commission_amount_nonneg CHECK (amount >= 0),
+ CONSTRAINT chk_ak_commission_status CHECK (status IN ('frozen','available','withdrawn','canceled'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_uid ON public.ak_commission_logs(uid);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_order_id ON public.ak_commission_logs(order_id);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_status ON public.ak_commission_logs(status);
+CREATE INDEX IF NOT EXISTS idx_ak_commission_logs_created_at ON public.ak_commission_logs(created_at);
+
+-- 启用 RLS
+ALTER TABLE public.ak_commission_logs ENABLE ROW LEVEL SECURITY;
+
+-- Admin 可读写
+CREATE POLICY "Admins can manage commission logs"
+ON public.ak_commission_logs
+FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 用户仅可查看自己的佣金流水
+CREATE POLICY "Users can view own commission logs"
+ON public.ak_commission_logs
+FOR SELECT
+TO authenticated
+USING (uid = auth.uid());
+-- =====================================================================================
+-- Schema: 分销代理商申请表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users, ak_distribution_divisions
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_agent_applications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid),
+
+ agent_name TEXT NOT NULL,
+ agent_phone TEXT NULL,
+
+ proof_images JSONB NULL, -- 申请凭证图片列表
+
+ status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
+ refusal_reason TEXT NULL,
+
+ approved_at TIMESTAMPTZ NULL,
+ approved_by UUID NULL REFERENCES public.ak_users(id),
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_uid ON public.ak_distribution_agent_applications(uid);
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_division_uid ON public.ak_distribution_agent_applications(division_uid);
+CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_status ON public.ak_distribution_agent_applications(status);
+
+COMMENT ON TABLE public.ak_distribution_agent_applications IS '分销代理商申请记录表';
+COMMENT ON COLUMN public.ak_distribution_agent_applications.proof_images IS '申请图片列表(JSON)';
+-- =====================================================================================
+-- Schema: 分销代理商管理表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users, ak_distribution_divisions
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_agents (
+ uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid), -- 所属事业部
+ name TEXT NOT NULL,
+ commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100),
+ is_enabled BOOLEAN DEFAULT TRUE,
+ end_time TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_distribution_agents_division_uid ON public.ak_distribution_agents(division_uid);
+
+-- 注释
+COMMENT ON TABLE public.ak_distribution_agents IS '分销代理商信息表';
+COMMENT ON COLUMN public.ak_distribution_agents.uid IS '用户ID(关联代理商本人)';
+COMMENT ON COLUMN public.ak_distribution_agents.division_uid IS '所属事业部UID';
+COMMENT ON COLUMN public.ak_distribution_agents.commission_ratio IS '代理商固定分佣比例(%)';
+-- 1. 创建分销配置表
+CREATE TABLE IF NOT EXISTS public.ak_distribution_config (
+ id TEXT PRIMARY KEY DEFAULT 'global_config',
+ is_enabled BOOLEAN DEFAULT true, -- 分销启用
+ extract_type TEXT DEFAULT '2', -- 分销模式: 1指定, 2人人, 3满额
+ bind_type TEXT DEFAULT '2', -- 绑定关系: 1所有用户, 2新用户
+ store_brokerage_binding_status TEXT DEFAULT '1', -- 绑定模式: 1永久, 2有效期, 3临时
+ brokerage_poster_status TEXT, -- 分销海报图路径/URL
+ brokerage_level INTEGER DEFAULT 2, -- 分销层级: 1, 2
+ is_area_manager BOOLEAN DEFAULT true, -- 事业部开关
+ is_agent_apply BOOLEAN DEFAULT true, -- 代理商申请开关
+ is_commission_window BOOLEAN DEFAULT true, -- 佣金悬浮窗开关
+
+ -- 返佣设置
+ is_self_brokerage BOOLEAN DEFAULT true, -- 自购返佣
+ is_member_brokerage BOOLEAN DEFAULT false, -- 购买会员返佣
+ brokerage_type TEXT DEFAULT '1', -- 返佣类型: 1价格, 2实付
+ is_promoter_brokerage BOOLEAN DEFAULT true, -- 推广用户返佣
+ promoter_brokerage_price DECIMAL(10,2) DEFAULT 2.00,
+ promoter_brokerage_day_max DECIMAL(10,2) DEFAULT -1.00,
+ store_brokerage_ratio DECIMAL(10,2) DEFAULT 20.00,
+ store_brokerage_two_ratio DECIMAL(10,2) DEFAULT 2.00,
+ extract_frozen_time INTEGER DEFAULT 1,
+
+ -- 提现设置
+ user_extract_min_price DECIMAL(10,2) DEFAULT 1.00,
+ extract_bank_list TEXT DEFAULT '中国银行',
+ extract_type_list TEXT[] DEFAULT ARRAY['bank', 'wechat', 'alipay'],
+ wechat_extract_type TEXT DEFAULT '1',
+ alipay_extract_type TEXT DEFAULT '1',
+ user_extract_fee DECIMAL(10,2) DEFAULT 0.00,
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 2. 启用 RLS
+ALTER TABLE public.ak_distribution_config ENABLE ROW LEVEL SECURITY;
+
+-- 3. 创建权限策略 (基于公共角色函数或直接查询 ak_users)
+-- 允许 Admin 查看配置
+CREATE POLICY "Admins can view distribution config"
+ON public.ak_distribution_config FOR SELECT
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 允许 Admin 修改配置
+CREATE POLICY "Admins can update distribution config"
+ON public.ak_distribution_config FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 4. 插入初始化数据
+INSERT INTO public.ak_distribution_config (id)
+VALUES ('global_config')
+ON CONFLICT (id) DO NOTHING;
+-- =====================================================================================
+-- Schema: 分销事业部申请表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_division_applications_v1.sql
+-- 说明:记录用户申请加入事业部成为代理商的流水,支持审核流转,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_division_applications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ division_id UUID NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 申请人填写的代理商名称
+ phone TEXT NOT NULL, -- 申请人联系电话
+ images JSONB DEFAULT '[]'::jsonb, -- 申请附件图片 (数组)
+
+ status INTEGER DEFAULT 1, -- 状态: 1待审核, 2已同意, 3已拒绝
+ admin_remark TEXT, -- 审核备注
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_distribution_division_applications ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略
+CREATE POLICY "Merchants manage their own applications"
+ON public.ak_distribution_division_applications FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许用户提交和查看自己的申请
+CREATE POLICY "Users handle own applications"
+ON public.ak_distribution_division_applications FOR ALL
+TO authenticated
+USING (uid = auth.uid())
+WITH CHECK (uid = auth.uid());
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_div_app_merchant ON public.ak_distribution_division_applications(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_div_app_uid ON public.ak_distribution_division_applications(uid);
+CREATE INDEX IF NOT EXISTS idx_div_app_status ON public.ak_distribution_division_applications(status);
+-- =====================================================================================
+-- Schema: 分销事业部管理表
+-- 位置:docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 依赖:ak_users
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_distribution_divisions (
+ uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ invite_code TEXT UNIQUE NOT NULL,
+ commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100),
+ is_enabled BOOLEAN DEFAULT TRUE,
+ end_time TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ created_by UUID REFERENCES public.ak_users(id),
+ updated_by UUID REFERENCES public.ak_users(id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_distribution_divisions_invite_code ON public.ak_distribution_divisions(invite_code);
+
+-- 注释
+COMMENT ON TABLE public.ak_distribution_divisions IS '分销事业部信息表';
+COMMENT ON COLUMN public.ak_distribution_divisions.uid IS '用户ID(关联事业部负责人)';
+COMMENT ON COLUMN public.ak_distribution_divisions.invite_code IS '事业部专属邀请码';
+COMMENT ON COLUMN public.ak_distribution_divisions.commission_ratio IS '事业部固定分佣比例(%)';
+COMMENT ON COLUMN public.ak_distribution_divisions.end_time IS '事业部有效截止时间';
+-- 1. 创建分销等级表
+CREATE TABLE IF NOT EXISTS public.ak_distribution_level (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL, -- 等级名称
+ level INTEGER NOT NULL UNIQUE, -- 等级权重/数字(如1, 2, 3)
+ percent1 DECIMAL(10,2) DEFAULT 0, -- 一级分佣比例 (%)
+ percent2 DECIMAL(10,2) DEFAULT 0, -- 二级分佣比例 (%)
+ task_total INTEGER DEFAULT 0, -- 任务总数
+ task_finish INTEGER DEFAULT 0, -- 需完成数量(升级门槛)
+ is_visible BOOLEAN DEFAULT true, -- 是否显示
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 启用 RLS
+ALTER TABLE public.ak_distribution_level ENABLE ROW LEVEL SECURITY;
+
+-- 3. 创建权限策略
+-- 允许所有认证用户查看等级(用于前端展示)
+CREATE POLICY "Anyone can view levels"
+ON public.ak_distribution_level FOR SELECT
+TO authenticated
+USING (true);
+
+-- 仅允许 Admin 进行管理 (INSERT/UPDATE/DELETE)
+CREATE POLICY "Admins can manage levels"
+ON public.ak_distribution_level FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 4. 插入初始化示例数据
+INSERT INTO public.ak_distribution_level (name, level, percent1, percent2, is_visible)
+VALUES ('普通分销员', 1, 10.00, 5.00, true)
+ON CONFLICT (level) DO NOTHING;
+-- 推广员关系表:记录下级与上级(邀请人)的绑定关系
+CREATE TABLE IF NOT EXISTS public.ak_promoter_relations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ inviter_uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ bind_time TIMESTAMPTZ DEFAULT now(),
+ created_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_ak_promoter_relations_no_self CHECK (uid <> inviter_uid),
+ CONSTRAINT uq_ak_promoter_relations_uid UNIQUE (uid)
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_promoter_relations_inviter_uid ON public.ak_promoter_relations(inviter_uid);
+
+-- 启用 RLS
+ALTER TABLE public.ak_promoter_relations ENABLE ROW LEVEL SECURITY;
+
+-- Admin 可读写
+CREATE POLICY "Admins can manage promoter relations"
+ON public.ak_promoter_relations
+FOR ALL
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+)
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+);
+
+-- 普通用户可查看与自己相关的关系(可选,便于移动端展示上级/下级)
+CREATE POLICY "Users can view their promoter relation"
+ON public.ak_promoter_relations
+FOR SELECT
+TO authenticated
+USING (uid = auth.uid() OR inviter_uid = auth.uid());
+-- =====================================================================================
+-- Schema: 用户提现申请表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:管理用户发起的提现申请(佣金/余额),支持多种提现方式及快照信息
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_extract (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ real_name TEXT NULL, -- 提现人姓名快照
+ extract_type TEXT NOT NULL, -- 提现方式: alipay, wechat, bank
+
+ -- 账号快照信息
+ alipay_code TEXT NULL, -- 支付宝账号
+ wechat_code TEXT NULL, -- 微信账号
+ bank_code TEXT NULL, -- 银行卡号
+ bank_address TEXT NULL, -- 开户行地址
+
+ extract_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 申请提现金额
+ service_fee DECIMAL(12,2) NOT NULL DEFAULT 0, -- 提现手续费
+ balance DECIMAL(12,2) NOT NULL DEFAULT 0, -- 提现时的余额快照
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 状态: 0:待审核, 1:已通过, -1:已驳回
+ refusal_reason TEXT NULL, -- 驳回原因
+
+ admin_id UUID NULL REFERENCES public.ak_users(id), -- 审核人ID
+ payment_time TIMESTAMPTZ NULL, -- 打款/到账时间
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_extract_uid_idx ON public.ml_extract (uid);
+CREATE INDEX IF NOT EXISTS ml_extract_status_idx ON public.ml_extract (status);
+CREATE INDEX IF NOT EXISTS ml_extract_created_at_idx ON public.ml_extract (created_at DESC);
+-- =====================================================================================
+-- Schema: 发票管理表
+-- 位置:docs/sql/10_schema/finance/ml_invoices_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户提交的开票申请及其处理状态
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_invoices (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+ order_no TEXT NOT NULL, -- 关联订单号
+
+ order_amount DECIMAL(12,2) NOT NULL, -- 订单金额
+
+ invoice_type SMALLINT NOT NULL DEFAULT 1, -- 1: 电子普通发票, 2: 增值税专用发票
+ header_type SMALLINT NOT NULL DEFAULT 1, -- 1: 个人, 2: 企业
+
+ header_name TEXT NOT NULL, -- 发票抬头
+ tax_id TEXT NULL, -- 企业税号
+
+ email TEXT NULL, -- 接收邮箱
+ remark TEXT NULL, -- 备注
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0: 待开票, 1: 已开票, -1: 已拒绝
+ refusal_reason TEXT NULL, -- 驳回原因
+ invoice_url TEXT NULL, -- 电子发票文件路径/URL
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_invoices_uid_idx ON public.ml_invoices (uid);
+CREATE INDEX IF NOT EXISTS ml_invoices_order_no_idx ON public.ml_invoices (order_no);
+CREATE INDEX IF NOT EXISTS ml_invoices_status_idx ON public.ml_invoices (status);
+-- =====================================================================================
+-- Schema: 用户资金流水表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户余额、积分、佣金的所有增减流水(原子日志)
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_user_bill (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ link_id TEXT NULL, -- 关联业务ID(订单号、提现ID、充值ID等)
+ pm SMALLINT NOT NULL DEFAULT 1, -- 0:支出, 1:收入
+
+ title TEXT NOT NULL, -- 流水标题(如:商品购买、充值、提现)
+ category TEXT NOT NULL, -- 业务大类(如:balance-余额, integral-积分, brokerage-佣金)
+ type TEXT NOT NULL, -- 业务子类型(如:recharge, extract, pay, refund, system_add, system_sub)
+
+ number DECIMAL(12,2) NOT NULL DEFAULT 0, -- 变动金额
+ balance DECIMAL(12,2) NOT NULL DEFAULT 0, -- 变动后的余额快照
+
+ mark TEXT NULL, -- 备注
+ status SMALLINT NOT NULL DEFAULT 1, -- 状态(1:有效, 0:无效/冲正)
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ml_user_bill_uid_idx ON public.ml_user_bill (uid);
+CREATE INDEX IF NOT EXISTS ml_user_bill_category_type_idx ON public.ml_user_bill (category, type);
+CREATE INDEX IF NOT EXISTS ml_user_bill_created_at_idx ON public.ml_user_bill (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户充值记录表
+-- 位置:docs/sql/10_schema/finance/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录用户主动发起的充值申请及支付状态
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ml_user_recharge (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ uid UUID NOT NULL REFERENCES public.ak_users(id),
+
+ order_no TEXT UNIQUE NOT NULL, -- 充值订单号(cz开头)
+ recharge_type TEXT NOT NULL, -- 充值渠道: wechat, alipay, system (后台补单)
+
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 实际充值金额
+ give_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 赠送金额
+
+ paid SMALLINT NOT NULL DEFAULT 0, -- 支付状态: 0:未支付, 1:已支付
+ pay_time TIMESTAMPTZ NULL, -- 支付时间
+
+ channel_trade_no TEXT NULL, -- 外部渠道流水号
+ status SMALLINT NOT NULL DEFAULT 1, -- 记录状态: 1:正常, 0:逻辑删除
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_user_recharge_uid_idx ON public.ml_user_recharge (uid);
+CREATE INDEX IF NOT EXISTS ml_user_recharge_order_no_idx ON public.ml_user_recharge (order_no);
+CREATE INDEX IF NOT EXISTS ml_user_recharge_created_at_idx ON public.ml_user_recharge (created_at DESC);
+-- =====================================================================================
+-- Schema: homecare foundation
+-- Version: v1
+-- Purpose: 为 ec_service_requests / ec_care_tasks / ec_care_records / hc_* 新链补齐最小结构、索引与基础 RLS。
+-- Coverage:
+-- 1. consumer 下单与自动派单字段
+-- 2. consumer 验收 / 退回整改 / 评价字段
+-- 3. delivery 执行记录、异常、证据、事件字段
+-- =====================================================================================
+
+-- 执行稿见:mall_sql/migrations/20260526_homecare_foundation_v1.sql-- =====================================================================================
+-- Schema: 客服模块核心表
+-- 位置:docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:包含客服账号、话术、留言及自动回复逻辑
+-- =====================================================================================
+
+-- 1. 客服人员表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_accounts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id), -- 关联主用户表
+
+ nickname TEXT NOT NULL, -- 客服昵称
+ avatar TEXT NULL, -- 客服头像
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 1:启用, 0:禁用
+ is_online BOOLEAN NOT NULL DEFAULT FALSE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 话术分类表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_word_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ sort INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 3. 客服快捷话术表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_words (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category_id UUID NOT NULL REFERENCES public.ml_kefu_word_categories(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ sort INT NOT NULL DEFAULT 0,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 4. 用户留言反馈表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_feedbacks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NULL REFERENCES public.ak_users(id), -- 允许匿名留言
+
+ nickname TEXT NULL,
+ phone TEXT NULL,
+ content TEXT NOT NULL,
+
+ status SMALLINT NOT NULL DEFAULT 0, -- 0:未处理, 1:已处理
+ reply_content TEXT NULL, -- 管理员回复内容
+ processed_at TIMESTAMPTZ NULL, -- 处理时间
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 5. 关键词自动回复表
+CREATE TABLE IF NOT EXISTS public.ml_kefu_auto_replies (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ keyword TEXT NOT NULL,
+ content TEXT NOT NULL,
+ reply_type TEXT NOT NULL DEFAULT 'text', -- text, image
+
+ status SMALLINT NOT NULL DEFAULT 1, -- 1:开启, 0:关闭
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS ml_kefu_accounts_user_id_idx ON public.ml_kefu_accounts (user_id);
+CREATE INDEX IF NOT EXISTS ml_kefu_words_category_id_idx ON public.ml_kefu_words (category_id);
+CREATE INDEX IF NOT EXISTS ml_kefu_feedbacks_status_idx ON public.ml_kefu_feedbacks (status);
+CREATE INDEX IF NOT EXISTS ml_kefu_auto_replies_keyword_idx ON public.ml_kefu_auto_replies (keyword);
+-- =====================================================================================
+-- Schema: 秒杀与拼团活动表
+-- 位置:docs/sql/10_schema/marketing/ak_advanced_marketing_v1.sql
+-- 说明:管理秒杀活动与拼团活动,按商家隔离。
+-- =====================================================================================
+
+-- 1. 秒杀活动表
+CREATE TABLE IF NOT EXISTS public.ak_seckill_activities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ single_limit INTEGER DEFAULT 1, -- 单次限购
+ total_limit INTEGER DEFAULT 10, -- 总购买数量限制
+ product_count INTEGER DEFAULT 0, -- 包含商品数量
+ time_range TEXT NOT NULL, -- 活动时段 (如 "06:00-24:00")
+
+ start_date TIMESTAMPTZ NOT NULL, -- 开始日期
+ end_date TIMESTAMPTZ NOT NULL, -- 结束日期
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 拼团活动表 (开团记录)
+CREATE TABLE IF NOT EXISTS public.ak_combination_activities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ uid UUID NOT NULL REFERENCES public.ak_users(id), -- 开团团长
+ product_id UUID NOT NULL REFERENCES public.ml_products(id), -- 拼团商品
+
+ people INTEGER DEFAULT 2, -- 几人团
+ count_people INTEGER DEFAULT 1, -- 当前几人参加
+
+ start_time TIMESTAMPTZ DEFAULT now(), -- 开团时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status TEXT NOT NULL DEFAULT 'ongoing', -- ongoing进行中, pending未完成, ended已成功
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ CONSTRAINT chk_comb_status CHECK (status IN ('ongoing', 'pending', 'ended'))
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_seckill_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_combination_activities ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 秒杀策略
+CREATE POLICY "Merchants can manage their own seckill activities"
+ON public.ak_seckill_activities FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 拼团策略
+CREATE POLICY "Merchants can manage their own combination activities"
+ON public.ak_combination_activities FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于移动端展示)
+CREATE POLICY "Anyone can view active marketing activities"
+ON public.ak_seckill_activities FOR SELECT
+TO authenticated
+USING (status = true);
+
+CREATE POLICY "Anyone can view ongoing combinations"
+ON public.ak_combination_activities FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema: 砍价与团购活动表
+-- 位置:docs/sql/10_schema/marketing/ak_bargain_groupbuy_v1.sql
+-- 说明:管理砍价与团购活动,按商家隔离。
+-- =====================================================================================
+
+-- 1. 砍价活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_bargains (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ min_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 砍价最低价
+ stock INTEGER DEFAULT 0, -- 活动库存
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 团购活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_groupbuys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ title TEXT NOT NULL, -- 活动标题
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 团购价格
+ people INTEGER DEFAULT 2, -- 成团人数要求
+ stock INTEGER DEFAULT 0, -- 活动库存
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_marketing_bargains ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_groupbuys ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 砍价策略
+CREATE POLICY "Merchants can manage their own bargains"
+ON public.ak_marketing_bargains FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 团购策略
+CREATE POLICY "Merchants can manage their own groupbuys"
+ON public.ak_marketing_groupbuys FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于移动端展示)
+CREATE POLICY "Anyone can view active marketing activities"
+ON public.ak_marketing_bargains FOR SELECT
+TO authenticated
+USING (status = true);
+
+CREATE POLICY "Anyone can view active groupbuys"
+ON public.ak_marketing_groupbuys FOR SELECT
+TO authenticated
+USING (status = true);
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_bargains_merchant ON public.ak_marketing_bargains(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_groupbuys_merchant ON public.ak_marketing_groupbuys(merchant_id);
+-- =====================================================================================
+-- Schema: 直播商品管理表
+-- 位置:docs/sql/10_schema/marketing/ak_live_products_v1.sql
+-- 说明:管理直播活动关联的商品,支持直播价设置与审核状态,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_products (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+
+ live_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 直播专属价
+ stock INTEGER DEFAULT 0, -- 直播可用库存
+
+ audit_status INTEGER DEFAULT 1, -- 审核状态: 1待审核, 2审核通过, 3审核驳回
+ is_show BOOLEAN DEFAULT true, -- 是否在直播间显示
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_live_products ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的直播商品
+CREATE POLICY "Merchants can manage their own live products"
+ON public.ak_marketing_live_products FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看审核通过的商品
+CREATE POLICY "Anyone can view approved live products"
+ON public.ak_marketing_live_products FOR SELECT
+TO authenticated
+USING (audit_status = 2 AND is_show = true);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_live_products_merchant ON public.ak_marketing_live_products(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_live_products_product ON public.ak_marketing_live_products(product_id);
+-- =====================================================================================
+-- Schema: 抽奖与直播管理表
+-- 位置:docs/sql/10_schema/marketing/ak_lottery_live_v1.sql
+-- 说明:管理抽奖活动、奖品、主播及直播间,按商家隔离。
+-- =====================================================================================
+
+-- 1. 抽奖活动表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_lotteries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 活动名称
+ type INTEGER DEFAULT 1, -- 活动类型: 1积分抽奖, 2订单评价, 3订单支付
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ end_time TIMESTAMPTZ NOT NULL, -- 结束时间
+
+ is_open BOOLEAN DEFAULT true, -- 是否开启
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 抽奖奖品表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_lottery_prizes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ lottery_id UUID NOT NULL REFERENCES public.ak_marketing_lotteries(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 奖品名称
+ prize_type TEXT NOT NULL, -- 奖品类型: points, balance, coupon, physical
+ amount DECIMAL(12,2) DEFAULT 0, -- 奖励面值/数量
+ stock INTEGER DEFAULT 0, -- 奖品库存
+ probability DECIMAL(5,2) DEFAULT 0, -- 中奖概率 (0-100)
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 直播主播表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_anchors (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ nickname TEXT NOT NULL, -- 主播昵称
+ wechat TEXT, -- 微信号
+ phone TEXT, -- 联系电话
+ avatar_url TEXT, -- 头像
+
+ status BOOLEAN DEFAULT true, -- 状态
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 4. 直播间管理表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_live_rooms (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ anchor_id UUID REFERENCES public.ak_marketing_live_anchors(id) ON DELETE SET NULL,
+
+ name TEXT NOT NULL, -- 直播间名称
+ background_url TEXT, -- 背景图
+ share_img_url TEXT, -- 分享图
+
+ start_time TIMESTAMPTZ NOT NULL, -- 开始时间
+ end_time TIMESTAMPTZ NOT NULL, -- 计划结束时间
+
+ sort INTEGER DEFAULT 0, -- 排序
+ type TEXT DEFAULT 'phone', -- 类型: phone手机直播等
+
+ like_enabled BOOLEAN DEFAULT true, -- 开启点赞
+ sale_enabled BOOLEAN DEFAULT true, -- 开启卖货
+ comment_enabled BOOLEAN DEFAULT true, -- 开启评论
+
+ is_show BOOLEAN DEFAULT true, -- 是否显示
+ live_status INTEGER DEFAULT 1, -- 1未开始, 2直播中, 3暂停, 4已结束
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 5. 启用 RLS
+ALTER TABLE public.ak_marketing_lotteries ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_lottery_prizes ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_anchors ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_rooms ENABLE ROW LEVEL SECURITY;
+
+-- 6. 创建权限策略 (按 merchant_id 隔离)
+CREATE POLICY "Merchants manage their own lotteries" ON public.ak_marketing_lotteries FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own anchors" ON public.ak_marketing_live_anchors FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own rooms" ON public.ak_marketing_live_rooms FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看
+CREATE POLICY "Users can view lotteries" ON public.ak_marketing_lotteries FOR SELECT TO authenticated USING (is_open = true);
+CREATE POLICY "Users can view active rooms" ON public.ak_marketing_live_rooms FOR SELECT TO authenticated USING (is_show = true);
+-- =====================================================================================
+-- Schema: 打卡/签到增强配置表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_checkin_configs_v1.sql
+-- 说明:管理打卡开关、模式、提醒及基础奖励(积分/经验),按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_checkin_configs (
+ id TEXT PRIMARY KEY DEFAULT 'checkin_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_open BOOLEAN DEFAULT true, -- 签到开关
+ mode TEXT DEFAULT 'none', -- 签到模式: none(无限制), week(周循环), month(月循环)
+ notice_enabled BOOLEAN DEFAULT false, -- 签到提醒开关
+
+ integral_reward INTEGER DEFAULT 10, -- 每日签到赠送积分
+ exp_reward INTEGER DEFAULT 1, -- 每日签到赠送经验
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_checkin_configs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的打卡配置
+CREATE POLICY "Merchants manage their own checkin configs"
+ON public.ak_marketing_checkin_configs FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看(用于前台展示)
+CREATE POLICY "Anyone can view checkin config"
+ON public.ak_marketing_checkin_configs FOR SELECT
+TO authenticated
+USING (true);
+
+-- 插入初始化数据(为每个管理员/商家初始化一条)
+-- 实际应在商家创建时触发,此处先预留
+-- =====================================================================================
+-- Schema: 新人礼配置表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_newcomer_config_v1.sql
+-- 说明:管理新用户注册后的奖励(余额、积分、优惠券),按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_newcomer_config (
+ id TEXT PRIMARY KEY DEFAULT 'newcomer_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ balance_reward DECIMAL(12,2) DEFAULT 0.00, -- 赠送余额
+ integral_reward INTEGER DEFAULT 0, -- 赠送积分
+
+ -- 赠送优惠券 (JSONB 格式): [{ "id": "coupon_uuid", "name": "显示名称", "desc": "发放描述" }]
+ coupons_json JSONB DEFAULT '[]'::jsonb,
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_newcomer_config ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的新人礼配置
+CREATE POLICY "Merchants manage their own newcomer configs"
+ON public.ak_marketing_newcomer_config FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看配置(用于移动端展示)
+CREATE POLICY "Anyone can view newcomer config"
+ON public.ak_marketing_newcomer_config FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema: 签到记录表
+-- 位置:docs/sql/10_schema/marketing/ak_marketing_signin_logs_v1.sql
+-- 说明:记录用户每日签到的详细流水,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_marketing_signin_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ points INTEGER NOT NULL DEFAULT 0, -- 本次签到获得的积分
+ is_continuous_reward BOOLEAN DEFAULT false, -- 是否包含连续签到额外奖励
+
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_marketing_signin_logs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理/查看自己的签到记录
+CREATE POLICY "Merchants manage their own signin logs"
+ON public.ak_marketing_signin_logs FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许用户查看自己的签到记录
+CREATE POLICY "Users view own signin logs"
+ON public.ak_marketing_signin_logs FOR SELECT
+TO authenticated
+USING (uid = auth.uid());
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_signin_logs_merchant ON public.ak_marketing_signin_logs(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_signin_logs_uid ON public.ak_marketing_signin_logs(uid, created_at DESC);
+-- =====================================================================================
+-- Schema: 付费会员管理相关表
+-- 位置:docs/sql/10_schema/marketing/ak_member_management_v1.sql
+-- 说明:管理会员卡类型、权益内容及基础配置,按商家隔离。
+-- =====================================================================================
+
+-- 1. 会员卡类型表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 会员名 (如: 月卡, 年卡)
+ duration_days INTEGER DEFAULT 30, -- 有效期(天),0表示永久
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 原价
+ discount_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 优惠价/实际支付价
+
+ is_open BOOLEAN DEFAULT true, -- 是否开启
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 会员权益表
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_rights (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 权益名称
+ description TEXT, -- 权益简介
+ icon_url TEXT, -- 权益图标
+
+ is_show BOOLEAN DEFAULT true, -- 是否展示
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 会员基础配置表 (每个商家一条记录)
+CREATE TABLE IF NOT EXISTS public.ak_marketing_member_config (
+ id TEXT PRIMARY KEY DEFAULT 'member_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_enabled BOOLEAN DEFAULT true, -- 是否开启付费会员功能
+ bg_img_url TEXT, -- 会员期内背景图
+ expire_bg_img_url TEXT, -- 会员到期背景图
+ rules_description TEXT, -- 会员规则说明文本
+
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 4. 启用 RLS
+ALTER TABLE public.ak_marketing_member_types ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_rights ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_config ENABLE ROW LEVEL SECURITY;
+
+-- 5. 创建权限策略 (按 merchant_id 隔离)
+-- 商家管理自己的数据
+CREATE POLICY "Merchants manage their own member types" ON public.ak_marketing_member_types FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own member rights" ON public.ak_marketing_member_rights FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own member config" ON public.ak_marketing_member_config FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看 (移动端展示)
+CREATE POLICY "Users can view active member types" ON public.ak_marketing_member_types FOR SELECT TO authenticated USING (is_open = true);
+CREATE POLICY "Users can view active member rights" ON public.ak_marketing_member_rights FOR SELECT TO authenticated USING (is_show = true);
+CREATE POLICY "Users can view member config" ON public.ak_marketing_member_config FOR SELECT TO authenticated USING (true);
+
+-- 6. 索引
+CREATE INDEX IF NOT EXISTS idx_member_types_merchant ON public.ak_marketing_member_types(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_member_rights_merchant ON public.ak_marketing_member_rights(merchant_id);
+-- =====================================================================================
+-- Schema: 充值配置与额度模板表
+-- 位置:docs/sql/10_schema/marketing/ak_recharge_management_v1.sql
+-- 说明:管理用户充值开关、最低金额及预设额度,按商家隔离。
+-- =====================================================================================
+
+-- 1. 充值基础配置表 (每个商家一条记录)
+CREATE TABLE IF NOT EXISTS public.ak_recharge_configs (
+ id TEXT PRIMARY KEY DEFAULT 'recharge_config',
+ merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ balance_enabled BOOLEAN DEFAULT true, -- 余额功能是否启用
+ recharge_notice TEXT, -- 充值注意事项说明
+ mp_recharge_enabled BOOLEAN DEFAULT false, -- 小程序充值开关
+ min_recharge_amount DECIMAL(12,2) DEFAULT 0.01, -- 最低充值金额
+
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 充值额度模板表
+CREATE TABLE IF NOT EXISTS public.ak_recharge_quotas (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 售价(实际充值金额)
+ bonus_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 赠送金额
+
+ is_open BOOLEAN DEFAULT true, -- 是否可用
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_recharge_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_quotas ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+CREATE POLICY "Merchants manage their own recharge configs" ON public.ak_recharge_configs FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+CREATE POLICY "Merchants manage their own recharge quotas" ON public.ak_recharge_quotas FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看
+CREATE POLICY "Users can view recharge configs" ON public.ak_recharge_configs FOR SELECT TO authenticated USING (true);
+CREATE POLICY "Users can view active recharge quotas" ON public.ak_recharge_quotas FOR SELECT TO authenticated USING (is_open = true);
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_recharge_quotas_merchant ON public.ak_recharge_quotas(merchant_id);
+-- =====================================================================================
+-- Schema: 签到规则配置表
+-- 位置:docs/sql/10_schema/marketing/ak_signin_configs_v1.sql
+-- 说明:记录每日签到积分、连续签到奖励及规则说明,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_signin_configs (
+ id TEXT PRIMARY KEY DEFAULT 'signin_config', -- 每个商家一个配置记录
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ is_enabled BOOLEAN DEFAULT true, -- 签到功能是否启用
+ daily_points INTEGER DEFAULT 10, -- 每日签到固定奖励积分
+
+ -- 连续签到奖励 (JSONB 格式): [{ "day": 3, "points": 20 }, { "day": 7, "points": 50 }]
+ continuous_rewards JSONB DEFAULT '[]'::jsonb,
+
+ rules_description TEXT DEFAULT '1.每日签到可获得积分奖励;\n2.连续签到满足天数可获得额外阶梯奖励;\n3.签到中断将重新从第一天开始计算。',
+
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ updated_by UUID REFERENCES auth.users(id),
+
+ -- 约束:同一个商家只有一个签到配置记录
+ UNIQUE(merchant_id)
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_signin_configs ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的签到配置
+CREATE POLICY "Merchants can manage their own signin configs"
+ON public.ak_signin_configs
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看配置(用于移动端签到展示)
+CREATE POLICY "Authenticated users can view signin configs"
+ON public.ak_signin_configs
+FOR SELECT
+TO authenticated
+USING (true);
+-- =====================================================================================
+-- Schema Update: ml_orders 字段补齐
+-- 位置:docs/sql/10_schema/order/
+-- 对象类型:Schema (ALTER TABLE)
+-- 版本:v1
+-- 说明:为订单主表补齐核销记录 RPC 所依赖的业务字段(order_type, verified_at, verifier_id)
+-- =====================================================================================
+
+DO $$
+BEGIN
+ -- 1. 补齐 order_type (1:普通, 2:收银, 3:核销)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'order_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN order_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.order_type IS '订单类型: 1:普通, 2:收银, 3:核销';
+ END IF;
+
+ -- 2. 补齐 verified_at (核销时间)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'verified_at') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN verified_at TIMESTAMP WITH TIME ZONE;
+ COMMENT ON COLUMN public.ml_orders.verified_at IS '核销时间';
+ END IF;
+
+ -- 3. 补齐 verifier_id (核销员ID)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'verifier_id') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN verifier_id UUID REFERENCES public.ak_users(id);
+ COMMENT ON COLUMN public.ml_orders.verifier_id IS '核销员ID';
+ END IF;
+
+ -- 4. 补齐 pay_type (支付方式: 1:余额, 2:微信, 3:支付宝, 4:线下支付)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'pay_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN pay_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.pay_type IS '支付方式: 1:余额, 2:微信, 3:支付宝, 4:线下支付';
+ END IF;
+
+ -- 5. 补齐 channel_type (订单渠道: 1:公众号, 2:小程序, 3:H5, 4:PC, 5:APP)
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_orders' AND column_name = 'channel_type') THEN
+ ALTER TABLE public.ml_orders ADD COLUMN channel_type INTEGER DEFAULT 1;
+ COMMENT ON COLUMN public.ml_orders.channel_type IS '订单渠道: 1:公众号, 2:小程序, 3:H5, 4:PC, 5:APP';
+ END IF;
+
+END $$;
+-- 1. 商品标签分组表
+CREATE TABLE IF NOT EXISTS public.ak_product_label_groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 2. 商品标签表
+CREATE TABLE IF NOT EXISTS public.ak_product_labels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ group_id UUID REFERENCES public.ak_product_label_groups(id) ON DELETE SET NULL,
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ is_active BOOLEAN DEFAULT true,
+ show_in_mobile BOOLEAN DEFAULT true,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 3. 启用 RLS
+ALTER TABLE public.ak_product_label_groups ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_product_labels ENABLE ROW LEVEL SECURITY;
+
+-- 4. 创建权限策略 (按 merchant_id 隔离)
+-- 分组策略
+CREATE POLICY "Users can manage their own label groups"
+ON public.ak_product_label_groups FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 标签策略
+CREATE POLICY "Users can manage their own labels"
+ON public.ak_product_labels FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 5. 索引
+CREATE INDEX IF NOT EXISTS idx_label_groups_merchant ON public.ak_product_label_groups(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_labels_group ON public.ak_product_labels(group_id);
+CREATE INDEX IF NOT EXISTS idx_labels_merchant ON public.ak_product_labels(merchant_id);
+-- =====================================================================================
+-- Schema: 商品会员价表 (按 SKU + 等级 维度)
+-- 位置:docs/sql/10_schema/product/ak_product_member_prices_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:记录特定商品 SKU 在不同会员等级下的专享价格,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_product_member_prices (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_id UUID NOT NULL REFERENCES public.ml_product_skus(id) ON DELETE CASCADE,
+ level_id UUID NOT NULL REFERENCES public.ak_user_levels(id) ON DELETE CASCADE,
+
+ member_price DECIMAL(12,2) NOT NULL CHECK (member_price >= 0),
+
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+
+ -- 约束:同一个商家的同一个 SKU 在同一个等级下只能有一个会员价
+ UNIQUE(merchant_id, sku_id, level_id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_product ON public.ak_product_member_prices(product_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_sku ON public.ak_product_member_prices(sku_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_level ON public.ak_product_member_prices(level_id);
+CREATE INDEX IF NOT EXISTS idx_prod_member_prices_merchant ON public.ak_product_member_prices(merchant_id);
+
+-- 启用 RLS
+ALTER TABLE public.ak_product_member_prices ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家仅能管理自己的商品会员价
+CREATE POLICY "Merchants can manage their own product member prices"
+ON public.ak_product_member_prices
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许所有认证用户查看会员价(前台下单需计算)
+CREATE POLICY "Authenticated users can view product member prices"
+ON public.ak_product_member_prices
+FOR SELECT
+TO authenticated
+USING (true);
+-- 商品保障/服务条款(按 merchant_id 隔离)
+CREATE TABLE IF NOT EXISTS public.ak_product_protections (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ icon_url TEXT,
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_ak_product_protections_merchant ON public.ak_product_protections(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_ak_product_protections_active ON public.ak_product_protections(is_active);
+
+ALTER TABLE public.ak_product_protections ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own product protections"
+ON public.ak_product_protections
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+-- 商品规格模板表 + 商品参数模板表(按 merchant_id 隔离)
+
+-- 1) 商品规格模板表
+CREATE TABLE IF NOT EXISTS public.ak_product_spec_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ specs TEXT NOT NULL DEFAULT '',
+ attrs TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_spec_templates_merchant ON public.ak_product_spec_templates(merchant_id);
+
+ALTER TABLE public.ak_product_spec_templates ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own spec templates"
+ON public.ak_product_spec_templates
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+
+-- 2) 商品参数模板表
+CREATE TABLE IF NOT EXISTS public.ak_product_param_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ params JSONB NOT NULL DEFAULT '[]'::jsonb,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_param_templates_merchant ON public.ak_product_param_templates(merchant_id);
+
+ALTER TABLE public.ak_product_param_templates ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can manage their own param templates"
+ON public.ak_product_param_templates
+FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+-- =====================================================================================
+-- Schema: 运费模板表
+-- 位置:docs/sql/10_schema/product/ak_shipping_templates_v1.sql
+-- 说明:管理商家的运费计算规则,按商家隔离。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_shipping_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 模板名称
+ calc_method TEXT DEFAULT 'piece', -- 计费方式: piece(件数), weight(重量), volume(体积)
+ is_free_shipping BOOLEAN DEFAULT false, -- 是否包邮
+
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 启用 RLS
+ALTER TABLE public.ak_shipping_templates ENABLE ROW LEVEL SECURITY;
+
+-- 权限策略:商家管理自己的模板
+CREATE POLICY "Merchants manage own shipping templates"
+ON public.ak_shipping_templates FOR ALL
+TO authenticated
+USING (merchant_id = auth.uid())
+WITH CHECK (merchant_id = auth.uid());
+
+-- 允许查看
+CREATE POLICY "Authenticated users view shipping templates"
+ON public.ak_shipping_templates FOR SELECT
+TO authenticated
+USING (true);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_shipping_templates_merchant ON public.ak_shipping_templates(merchant_id);
+-- Schema: 医疗商城独立分类与商品分类关联
+-- 执行阶段: 10_schema
+
+CREATE TABLE IF NOT EXISTS public.medical_mall_categories (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ parent_id TEXT NULL REFERENCES public.medical_mall_categories(id),
+ level INTEGER NOT NULL CHECK (level IN (1, 2)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ icon TEXT,
+ image_url TEXT,
+ description TEXT,
+ scene TEXT NOT NULL DEFAULT 'medical_mall',
+ category_type TEXT NOT NULL DEFAULT 'normal',
+ compliance_type TEXT NOT NULL DEFAULT 'normal',
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ deleted_at TIMESTAMPTZ NULL,
+ deleted_by UUID NULL REFERENCES public.ak_users(id),
+ restored_at TIMESTAMPTZ NULL,
+ restored_by UUID NULL REFERENCES public.ak_users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.medical_mall_categories IS '医疗商城独立分类表';
+COMMENT ON COLUMN public.medical_mall_categories.scene IS '分类场景,如 medical_mall / home_mall';
+COMMENT ON COLUMN public.medical_mall_categories.category_type IS '分类类型:device / otc / care / rehab / elderly / nutrition / protection / tcm / all';
+COMMENT ON COLUMN public.medical_mall_categories.compliance_type IS '合规类型:normal / otc / device_class_i / device_class_ii / device_class_iii / rx_hidden';
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_parent
+ ON public.medical_mall_categories(parent_id)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_level_sort
+ ON public.medical_mall_categories(level, sort_order)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_categories_scene_active
+ ON public.medical_mall_categories(scene, is_active)
+ WHERE deleted_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS public.medical_mall_product_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ category_id TEXT NOT NULL REFERENCES public.medical_mall_categories(id),
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ NULL,
+ deleted_by UUID NULL REFERENCES public.ak_users(id),
+ restored_at TIMESTAMPTZ NULL,
+ restored_by UUID NULL REFERENCES public.ak_users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (product_id, category_id)
+);
+
+COMMENT ON TABLE public.medical_mall_product_categories IS '医疗商城商品与分类关联表';
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_product_categories_category
+ ON public.medical_mall_product_categories(category_id, sort_order)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_medical_mall_product_categories_product
+ ON public.medical_mall_product_categories(product_id)
+ WHERE deleted_at IS NULL;-- =====================================================================================
+-- Schema Update: ml_products 扩展字段 (物流、营销、高级设置)
+-- 位置:docs/sql/10_schema/product/ml_products_ext_v1.sql
+-- 说明:补齐商品编辑页 Step 3-6 所需的持久化字段。
+-- =====================================================================================
+
+DO $$
+BEGIN
+ -- 1. 物流设置:关联运费模板
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'shipping_template_id') THEN
+ ALTER TABLE public.ml_products ADD COLUMN shipping_template_id UUID REFERENCES public.ak_shipping_templates(id) ON DELETE SET NULL;
+ COMMENT ON COLUMN public.ml_products.shipping_template_id IS '关联运费模板ID';
+ END IF;
+
+ -- 2. 营销设置:赠送积分
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'give_integral') THEN
+ ALTER TABLE public.ml_products ADD COLUMN give_integral INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.give_integral IS '购买赠送积分';
+ END IF;
+
+ -- 3. 高级设置:警戒库存
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'stock_warning') THEN
+ ALTER TABLE public.ml_products ADD COLUMN stock_warning INTEGER DEFAULT 10;
+ COMMENT ON COLUMN public.ml_products.stock_warning IS '库存报警数值';
+ END IF;
+
+ -- 4. 高级设置:虚拟销量
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'virtual_sales') THEN
+ ALTER TABLE public.ml_products ADD COLUMN virtual_sales INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.virtual_sales IS '虚拟销量(展示用)';
+ END IF;
+
+ -- 5. 高级设置:排序
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'sort_order') THEN
+ ALTER TABLE public.ml_products ADD COLUMN sort_order INTEGER DEFAULT 0;
+ COMMENT ON COLUMN public.ml_products.sort_order IS '商品排序权重';
+ END IF;
+
+END $$;
+-- =====================================================================================
+-- Schema: 权限管理 (RBAC) 核心表
+-- 位置:docs/sql/10_schema/user/ak_auth_system_v1.sql
+-- 对象类型:TABLE
+-- 版本:v1
+-- 说明:包含角色表、权限/菜单表及用户角色关联表
+-- =====================================================================================
+
+-- 1. 角色表
+CREATE TABLE IF NOT EXISTS public.ak_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE, -- 角色名称 (如: 超级管理员)
+ code TEXT NOT NULL UNIQUE, -- 角色编码 (如: super_admin)
+ description TEXT, -- 角色描述
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 2. 权限/菜单表
+CREATE TABLE IF NOT EXISTS public.ak_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ parent_id UUID REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
+
+ name TEXT NOT NULL, -- 权限/菜单名称
+ code TEXT NOT NULL UNIQUE, -- 权限编码 (如: order_view)
+ type TEXT NOT NULL, -- 类型: menu(菜单), button(按钮/接口)
+
+ path TEXT, -- 前端路由路径 (仅针对 menu)
+ icon TEXT, -- 图标
+ sort_order INTEGER DEFAULT 0, -- 排序
+
+ is_visible BOOLEAN DEFAULT TRUE, -- 菜单是否在左侧可见
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 3. 用户-角色关联表
+-- 映射管理员 (ak_users) 与角色
+CREATE TABLE IF NOT EXISTS public.ak_admin_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
+
+ assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ assigned_by UUID REFERENCES public.ak_users(id),
+
+ UNIQUE(user_id, role_id)
+);
+
+-- 4. 角色-权限关联表
+CREATE TABLE IF NOT EXISTS public.ak_role_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
+ permission_id UUID NOT NULL REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
+
+ UNIQUE(role_id, permission_id)
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_permissions_parent_id ON public.ak_permissions(parent_id);
+CREATE INDEX IF NOT EXISTS idx_admin_roles_user_id ON public.ak_admin_roles(user_id);
+CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON public.ak_role_permissions(role_id);
+
+-- 注释
+COMMENT ON TABLE public.ak_roles IS '后台管理角色表';
+COMMENT ON TABLE public.ak_permissions IS '功能权限与菜单定义表';
+COMMENT ON TABLE public.ak_admin_roles IS '管理员角色分配表';
+COMMENT ON TABLE public.ak_role_permissions IS '角色权限映射表';
+-- =====================================================================================
+-- Schema: 用户分组表
+-- 位置:docs/sql/10_schema/user/ak_user_groups_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户分组定义,支持逻辑删除和状态管理
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ remark TEXT NULL,
+ status INT NOT NULL DEFAULT 1, -- 1:启用, 0:禁用
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_groups_name_length CHECK (char_length(name) >= 1)
+);
+
+-- 唯一性约束(仅对未删除记录生效)
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_groups_name_uniq_active
+ ON public.ak_user_groups (name)
+ WHERE deleted_at IS NULL;
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ak_user_groups_status_idx ON public.ak_user_groups (status) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS ak_user_groups_created_at_idx ON public.ak_user_groups (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户标签表
+-- 位置:docs/sql/10_schema/user/ak_user_labels_v1.sql
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户标签定义,支持逻辑删除与状态管理
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_labels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ color TEXT NULL,
+ remark TEXT NULL,
+ status INT NOT NULL DEFAULT 1,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_labels_name_length CHECK (char_length(name) >= 1)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_labels_name_uniq_active
+ ON public.ak_user_labels (name)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_labels_status_idx
+ ON public.ak_user_labels (status)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_labels_created_at_idx
+ ON public.ak_user_labels (created_at DESC);
+-- =====================================================================================
+-- Schema: 用户等级表
+-- 位置:docs/sql/10_schema/user/
+-- 对象类型:Schema (DDL)
+-- 版本:v1
+-- 说明:用户等级(经验值/折扣/展示/状态),支持逻辑删除
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.ak_user_levels (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ level_weight INT NOT NULL,
+ min_experience INT NOT NULL DEFAULT 0,
+ discount_percent INT NOT NULL DEFAULT 100,
+
+ is_visible BOOLEAN NOT NULL DEFAULT TRUE,
+ status INT NOT NULL DEFAULT 1,
+
+ icon_url TEXT NULL,
+ bg_image_url TEXT NULL,
+ bg_style_json JSONB NULL,
+
+ remark TEXT NULL,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+
+ CONSTRAINT ak_user_levels_level_weight_nonnegative CHECK (level_weight >= 0),
+ CONSTRAINT ak_user_levels_min_experience_nonnegative CHECK (min_experience >= 0),
+ CONSTRAINT ak_user_levels_discount_percent_range CHECK (discount_percent BETWEEN 1 AND 100)
+);
+
+-- 唯一性(仅对未删除记录生效)
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_levels_name_uniq_active
+ ON public.ak_user_levels (name)
+ WHERE deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS ak_user_levels_level_weight_uniq_active
+ ON public.ak_user_levels (level_weight)
+ WHERE deleted_at IS NULL;
+
+-- 常用查询索引
+CREATE INDEX IF NOT EXISTS ak_user_levels_active_filter_idx
+ ON public.ak_user_levels (status, is_visible)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_levels_min_experience_idx
+ ON public.ak_user_levels (min_experience)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS ak_user_levels_level_weight_desc_idx
+ ON public.ak_user_levels (level_weight DESC)
+ WHERE deleted_at IS NULL;
+-- Schema Update: public.ak_users 增加真实姓名与手机号字段(用于分销/推广员等管理端展示)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'ak_users' AND column_name = 'real_name'
+ ) THEN
+ ALTER TABLE public.ak_users ADD COLUMN real_name text;
+ COMMENT ON COLUMN public.ak_users.real_name IS '真实姓名(可选)';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'ak_users' AND column_name = 'phone'
+ ) THEN
+ ALTER TABLE public.ak_users ADD COLUMN phone text;
+ COMMENT ON COLUMN public.ak_users.phone IS '手机号(可选)';
+ END IF;
+END $$;
+-- =====================================================================================
+-- Schema: public.ak_users
+-- Version: v1
+-- Purpose: 修复 auth.users -> ak_users 自动同步的写入协调问题
+-- Change: 放宽 username 和 email 的 NOT NULL 约束,以允许数据库触发器成功插入新用户记录。
+-- 同时,将 role 的默认值更新为 'customer' 以符合业务逻辑。
+-- =====================================================================================
+
+BEGIN;
+
+-- 步骤 1 & 2: 允许 username/email 为空,并更新 role 默认值
+-- 这样数据库的自动用户同步触发器就不会因为缺少 NOT NULL 的值而失败。
+-- 前端代码 (ensureUserProfile) 会在用户首次登录时尝试填充这些值。
+ALTER TABLE public.ak_users
+ ALTER COLUMN username DROP NOT NULL,
+ ALTER COLUMN email DROP NOT NULL,
+ ALTER COLUMN role SET DEFAULT 'customer';
+
+COMMIT;
+-- =====================================================================================
+-- User 模块扩展 - 财务字段补全
+-- 位置:docs/sql/10_schema/user/
+-- 版本:v1
+-- 描述:为 ak_users 增加余额与佣金字段,支持财务业务。
+-- =====================================================================================
+
+ALTER TABLE public.ak_users
+ADD COLUMN IF NOT EXISTS now_money DECIMAL(12,2) DEFAULT 0,
+ADD COLUMN IF NOT EXISTS brokerage_price DECIMAL(12,2) DEFAULT 0;
+
+COMMENT ON COLUMN public.ak_users.now_money IS '用户当前余额';
+COMMENT ON COLUMN public.ak_users.brokerage_price IS '用户当前佣金';
+
+```
+
+### 3) RLS(行级安全)
+```sql
+-- =====================================================================================
+-- RLS: 系统配置表安全策略
+-- 位置:docs/sql/20_rls/admin/ml_system_configs_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:允许所有登录用户读取配置;管理端全量操作通过 RPC (SECURITY DEFINER) 执行
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_system_configs ENABLE ROW LEVEL SECURITY;
+
+-- 1. 允许所有登录用户读取配置 (用于前端业务逻辑判断)
+DROP POLICY IF EXISTS system_configs_select_policy ON public.ml_system_configs;
+CREATE POLICY system_configs_select_policy ON public.ml_system_configs
+FOR SELECT TO authenticated USING (deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 权限管理 (Auth) 安全策略
+-- 位置:docs/sql/20_rls/auth/ak_auth_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:角色与权限表默认不对外开放,全量管理通过 SECURITY DEFINER RPC 执行
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_roles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_permissions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_admin_roles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_role_permissions ENABLE ROW LEVEL SECURITY;
+
+-- 默认策略:NO DIRECT ACCESS
+-- 所有的查询和修改均建议通过 docs/sql/30_rpc/auth/ 下的专用管理接口完成
+-- 这样可以确保鉴权逻辑与 ak_users.role 强制绑定,且具备审计能力
+-- =====================================================================================
+-- RLS: 内容管理模块安全策略
+-- 位置:docs/sql/20_rls/cms/ml_cms_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端可读(仅已发布/启用);管理端通过 RPC 访问
+-- =====================================================================================
+
+-- 1. 开启 RLS
+ALTER TABLE public.ml_article_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_articles ENABLE ROW LEVEL SECURITY;
+
+-- 2. 分类表策略:允许所有人读取启用的分类
+DROP POLICY IF EXISTS ml_article_categories_select_active ON public.ml_article_categories;
+CREATE POLICY ml_article_categories_select_active
+ ON public.ml_article_categories
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 3. 文章表策略:允许所有人读取已发布的文章
+DROP POLICY IF EXISTS ml_articles_select_published ON public.ml_articles;
+CREATE POLICY ml_articles_select_published
+ ON public.ml_articles
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,管理端操作通过 RPC (SECURITY DEFINER) 执行
+-- =====================================================================================
+-- RLS: 装修模块 - DIY 页面安全策略
+-- 位置:docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端公开只读已发布的页面;管理端通过 SECURITY DEFINER RPC 进行管理
+-- =====================================================================================
+
+-- 1. 启用 RLS
+ALTER TABLE public.ak_diy_pages ENABLE ROW LEVEL SECURITY;
+
+-- 2. 消费者端策略:允许匿名和登录用户读取已启用的页面
+DROP POLICY IF EXISTS diy_pages_select_active ON public.ak_diy_pages;
+CREATE POLICY diy_pages_select_active ON public.ak_diy_pages
+FOR SELECT TO anon, authenticated
+USING (is_active = true AND deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 物流设置 (Delivery) 安全策略
+-- 位置:docs/sql/20_rls/delivery/ak_delivery_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:配送员表管理端私有;提货点表消费者端只读
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_delivery_staff ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_delivery_stations ENABLE ROW LEVEL SECURITY;
+
+-- 1. 配送员表策略:默认不开放直接访问
+-- 全量管理通过 docs/sql/30_rpc/delivery/ 下的 RPC 执行
+
+-- 2. 提货点表策略:允许消费者端只读(用于地图展示和下单选择)
+DROP POLICY IF EXISTS delivery_stations_select_active ON public.ml_delivery_stations;
+CREATE POLICY delivery_stations_select_active
+ ON public.ml_delivery_stations
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行
+-- =====================================================================================
+-- RLS: 医养执行端 Delivery 安全策略升级
+-- 位置:docs/sql/20_rls/delivery/ak_delivery_rls_v2.sql
+-- 对象类型:RLS 策略
+-- 版本:v2
+-- 说明:保留管理端通过 SECURITY DEFINER RPC 管理,补充执行人员本人直读自己档案。
+-- =====================================================================================
+
+ALTER TABLE public.ml_delivery_staff ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_delivery_stations ENABLE ROW LEVEL SECURITY;
+
+-- 清理旧策略
+DROP POLICY IF EXISTS delivery_staff_self_select ON public.ml_delivery_staff;
+DROP POLICY IF EXISTS delivery_staff_self_update ON public.ml_delivery_staff;
+DROP POLICY IF EXISTS delivery_stations_select_active ON public.ml_delivery_stations;
+
+-- 1. 执行人员本人可直读自己的未删除档案
+CREATE POLICY delivery_staff_self_select
+ ON public.ml_delivery_staff
+ FOR SELECT
+ TO authenticated
+ USING (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ );
+
+-- 2. 执行人员本人可更新自己的在线状态等自有档案字段
+CREATE POLICY delivery_staff_self_update
+ ON public.ml_delivery_staff
+ FOR UPDATE
+ TO authenticated
+ USING (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.id = ml_delivery_staff.uid
+ AND u.auth_id = auth.uid()
+ )
+ );
+
+-- 3. 提货点/机构对前台保持只读,仅返回启用且未删除数据
+CREATE POLICY delivery_stations_select_active
+ ON public.ml_delivery_stations
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 1 AND deleted_at IS NULL);
+
+-- 4. 派单候选人读取统一走 SECURITY DEFINER RPC,不再开放公开可派单列表 RLS
+-- 5. 其余直连写操作默认不开放,管理端统一走 SECURITY DEFINER RPC
+-- =====================================================================================
+-- RLS: 分销模块安全策略
+-- 位置:docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量权限通过 SECURITY DEFINER RPC 执行;用户仅能访问个人关联数据
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_distribution_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_level ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_promoter_relations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_commission_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_divisions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_agents ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_distribution_agent_applications ENABLE ROW LEVEL SECURITY;
+
+-- 1. 分销配置:允许所有登录用户读取(消费者端展示逻辑需要)
+DROP POLICY IF EXISTS dist_config_select_policy ON public.ak_distribution_config;
+CREATE POLICY dist_config_select_policy ON public.ak_distribution_config
+FOR SELECT TO authenticated USING (deleted_at IS NULL);
+
+-- 2. 分销等级:允许所有登录用户读取可见等级
+DROP POLICY IF EXISTS dist_level_select_policy ON public.ak_distribution_level;
+CREATE POLICY dist_level_select_policy ON public.ak_distribution_level
+FOR SELECT TO authenticated USING (is_visible = true AND deleted_at IS NULL);
+
+-- 3. 推广员关系:用户仅能查看与自己相关的记录
+DROP POLICY IF EXISTS promoter_relations_select_policy ON public.ak_promoter_relations;
+CREATE POLICY promoter_relations_select_policy ON public.ak_promoter_relations
+FOR SELECT TO authenticated USING ((uid = auth.uid() OR inviter_uid = auth.uid()) AND deleted_at IS NULL);
+
+-- 4. 佣金日志:用户仅能查看自己的佣金记录
+DROP POLICY IF EXISTS commission_logs_select_policy ON public.ak_commission_logs;
+CREATE POLICY commission_logs_select_policy ON public.ak_commission_logs
+FOR SELECT TO authenticated USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 5. 事业部与代理商:允许登录用户查看启用的记录
+DROP POLICY IF EXISTS dist_divisions_select_policy ON public.ak_distribution_divisions;
+CREATE POLICY dist_divisions_select_policy ON public.ak_distribution_divisions
+FOR SELECT TO authenticated USING (is_enabled = true AND deleted_at IS NULL);
+
+DROP POLICY IF EXISTS dist_agents_select_policy ON public.ak_distribution_agents;
+CREATE POLICY dist_agents_select_policy ON public.ak_distribution_agents
+FOR SELECT TO authenticated USING (is_enabled = true AND deleted_at IS NULL);
+
+-- 6. 代理商申请:用户仅能管理自己的申请记录
+DROP POLICY IF EXISTS dist_apply_user_policy ON public.ak_distribution_agent_applications;
+CREATE POLICY dist_apply_user_policy ON public.ak_distribution_agent_applications
+FOR ALL TO authenticated USING (uid = auth.uid() AND deleted_at IS NULL) WITH CHECK (uid = auth.uid());
+
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作
+-- =====================================================================================
+-- RLS: 用户提现申请表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的提现记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_extract ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的提现申请
+DROP POLICY IF EXISTS ml_extract_user_select ON public.ml_extract;
+CREATE POLICY ml_extract_user_select
+ ON public.ml_extract
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户
+-- 提现申请通常由特定的 RPC 函数 (security definer) 创建,以确保业务逻辑(如冻结余额)的原子性
+-- =====================================================================================
+-- RLS: 发票管理表
+-- 位置:docs/sql/20_rls/finance/ml_invoices_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:用户仅能查看自己的开票申请;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_invoices ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_invoices_user_select ON public.ml_invoices;
+CREATE POLICY ml_invoices_user_select
+ ON public.ml_invoices
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,通常由 RPC 或支付后逻辑触发
+-- =====================================================================================
+-- RLS: 用户资金流水表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的流水记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_user_bill ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_user_bill_user_select ON public.ml_user_bill;
+CREATE POLICY ml_user_bill_user_select
+ ON public.ml_user_bill
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,由后端逻辑或 RPC 触发
+-- =====================================================================================
+-- RLS: 用户充值记录表
+-- 位置:docs/sql/20_rls/finance/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:仅允许用户查看自己的充值记录;管理端通过 RPC 访问
+-- =====================================================================================
+
+ALTER TABLE public.ml_user_recharge ENABLE ROW LEVEL SECURITY;
+
+-- 策略 1: 允许用户读取自己的记录(仅未删除数据)
+DROP POLICY IF EXISTS ml_user_recharge_user_select ON public.ml_user_recharge;
+CREATE POLICY ml_user_recharge_user_select
+ ON public.ml_user_recharge
+ FOR SELECT
+ TO authenticated
+ USING (uid = auth.uid() AND deleted_at IS NULL);
+
+-- 默认不开放 INSERT/UPDATE/DELETE 给普通用户,写操作通常由业务逻辑或支付回调触发
+-- =====================================================================================
+-- RLS: 客服模块安全策略
+-- 位置:docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;用户仅能操作自己的留言反馈
+-- =====================================================================================
+
+-- 开启所有表的 RLS
+ALTER TABLE public.ml_kefu_accounts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_word_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_words ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_feedbacks ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_kefu_auto_replies ENABLE ROW LEVEL SECURITY;
+
+-- 1. 留言反馈表策略
+-- 允许登录用户插入自己的留言
+DROP POLICY IF EXISTS ml_kefu_feedbacks_user_insert ON public.ml_kefu_feedbacks;
+CREATE POLICY ml_kefu_feedbacks_user_insert
+ ON public.ml_kefu_feedbacks
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (user_id = auth.uid());
+
+-- 允许用户查看自己的留言
+DROP POLICY IF EXISTS ml_kefu_feedbacks_user_select ON public.ml_kefu_feedbacks;
+CREATE POLICY ml_kefu_feedbacks_user_select
+ ON public.ml_kefu_feedbacks
+ FOR SELECT
+ TO authenticated
+ USING (user_id = auth.uid());
+
+-- 其他表(账号、话术、自动回复)默认不向 anon/authenticated 角色开放 SELECT/INSERT/UPDATE/DELETE
+-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 函数执行
+-- =====================================================================================
+-- RLS Policy: 优惠券模板表权限控制
+-- 位置:docs/sql/20_rls/marketing/ml_coupon_templates_rls_v1.sql
+-- 说明:确保商家仅能管理自己的优惠券模板,管理员拥有全权限。
+-- =====================================================================================
+
+-- 1. 启用 RLS
+ALTER TABLE public.ml_coupon_templates ENABLE ROW LEVEL SECURITY;
+
+-- 2. 创建权限策略
+-- 允许商家管理自己的模板
+CREATE POLICY ml_coupon_templates_merchant_policy ON public.ml_coupon_templates
+ FOR ALL
+ TO authenticated
+ USING (
+ merchant_id = auth.uid() OR
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+ )
+ WITH CHECK (
+ merchant_id = auth.uid() OR
+ EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ )
+ );
+
+-- 允许所有认证用户查看模板(用于前台领取)
+CREATE POLICY ml_coupon_templates_select_policy ON public.ml_coupon_templates
+ FOR SELECT
+ TO authenticated
+ USING (status = 1);
+-- =====================================================================================
+-- RLS: 营销核心活动表安全策略
+-- 位置:docs/sql/20_rls/marketing/ml_marketing_activities_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端公开只读;管理端操作由 RPC (SECURITY DEFINER) 承载
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_seckill_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_combination_activities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_bargains ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_groupbuys ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_lotteries ENABLE ROW LEVEL SECURITY;
+
+-- 1. 秒杀活动:公开只读
+DROP POLICY IF EXISTS seckill_select_policy ON public.ak_seckill_activities;
+CREATE POLICY seckill_select_policy ON public.ak_seckill_activities FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 2. 拼团活动:公开只读
+DROP POLICY IF EXISTS combination_select_policy ON public.ak_combination_activities;
+CREATE POLICY combination_select_policy ON public.ak_combination_activities FOR SELECT TO anon, authenticated USING (status = 'ongoing');
+
+-- 3. 砍价活动:公开只读
+DROP POLICY IF EXISTS bargain_select_policy ON public.ak_marketing_bargains;
+CREATE POLICY bargain_select_policy ON public.ak_marketing_bargains FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 4. 团购活动:公开只读
+DROP POLICY IF EXISTS groupbuy_select_policy ON public.ak_marketing_groupbuys;
+CREATE POLICY groupbuy_select_policy ON public.ak_marketing_groupbuys FOR SELECT TO anon, authenticated USING (status = true);
+
+-- 5. 抽奖活动:公开只读
+DROP POLICY IF EXISTS lottery_select_policy ON public.ak_marketing_lotteries;
+CREATE POLICY lottery_select_policy ON public.ak_marketing_lotteries FOR SELECT TO anon, authenticated USING (is_open = true);
+
+-- 管理端全量管理将由 SECURITY DEFINER 的 RPC 接口执行
+-- =====================================================================================
+-- RLS: 营销模块其他业务表安全策略 (互动/会员/直播/充值)
+-- 位置:docs/sql/20_rls/marketing/ml_marketing_others_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:配置类公开只读;记录类用户隔离;管理端由 RPC 承载
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ak_signin_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_signin_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_newcomer_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_types ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_rights ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_member_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_anchors ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_marketing_live_rooms ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_configs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ak_recharge_quotas ENABLE ROW LEVEL SECURITY;
+
+-- 1. 签到与新人礼配置:公开只读
+DROP POLICY IF EXISTS signin_config_select_policy ON public.ak_signin_configs;
+CREATE POLICY signin_config_select_policy ON public.ak_signin_configs FOR SELECT TO authenticated USING (true);
+
+DROP POLICY IF EXISTS newcomer_config_select_policy ON public.ak_marketing_newcomer_config;
+CREATE POLICY newcomer_config_select_policy ON public.ak_marketing_newcomer_config FOR SELECT TO authenticated USING (true);
+
+-- 2. 签到日志:用户仅能查看自己的
+DROP POLICY IF EXISTS signin_logs_user_policy ON public.ak_marketing_signin_logs;
+CREATE POLICY signin_logs_user_policy ON public.ak_marketing_signin_logs
+FOR SELECT TO authenticated USING (uid = auth.uid());
+
+-- 3. 会员体系:类型与权益公开只读
+DROP POLICY IF EXISTS member_types_select_policy ON public.ak_marketing_member_types;
+CREATE POLICY member_types_select_policy ON public.ak_marketing_member_types FOR SELECT TO authenticated USING (is_open = true);
+
+DROP POLICY IF EXISTS member_rights_select_policy ON public.ak_marketing_member_rights;
+CREATE POLICY member_rights_select_policy ON public.ak_marketing_member_rights FOR SELECT TO authenticated USING (is_show = true);
+
+DROP POLICY IF EXISTS member_config_select_policy ON public.ak_marketing_member_config;
+CREATE POLICY member_config_select_policy ON public.ak_marketing_member_config FOR SELECT TO authenticated USING (is_enabled = true);
+
+-- 4. 直播:公开只读
+DROP POLICY IF EXISTS live_anchors_select_policy ON public.ak_marketing_live_anchors;
+CREATE POLICY live_anchors_select_policy ON public.ak_marketing_live_anchors FOR SELECT TO authenticated USING (status = true);
+
+DROP POLICY IF EXISTS live_rooms_select_policy ON public.ak_marketing_live_rooms;
+CREATE POLICY live_rooms_select_policy ON public.ak_marketing_live_rooms FOR SELECT TO authenticated USING (is_show = true);
+
+-- 5. 充值配置:公开只读
+DROP POLICY IF EXISTS recharge_config_select_policy ON public.ak_recharge_configs;
+CREATE POLICY recharge_config_select_policy ON public.ak_recharge_configs FOR SELECT TO authenticated USING (balance_enabled = true);
+
+DROP POLICY IF EXISTS recharge_quotas_select_policy ON public.ak_recharge_quotas;
+CREATE POLICY recharge_quotas_select_policy ON public.ak_recharge_quotas FOR SELECT TO authenticated USING (is_open = true);
+
+-- 管理端全量管理均通过 SECURITY DEFINER 的 RPC 接口执行
+-- RLS: 医疗商城独立分类与商品分类关联
+-- 执行阶段: 20_rls
+
+ALTER TABLE public.medical_mall_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.medical_mall_product_categories ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS medical_mall_categories_public_select_active ON public.medical_mall_categories;
+CREATE POLICY medical_mall_categories_public_select_active
+ON public.medical_mall_categories
+FOR SELECT
+TO anon, authenticated
+USING (
+ deleted_at IS NULL
+ AND is_active = TRUE
+ AND compliance_type <> 'rx_hidden'
+);
+
+DROP POLICY IF EXISTS medical_mall_product_categories_public_select_active ON public.medical_mall_product_categories;
+CREATE POLICY medical_mall_product_categories_public_select_active
+ON public.medical_mall_product_categories
+FOR SELECT
+TO anon, authenticated
+USING (deleted_at IS NULL);-- =====================================================================================
+-- RLS: 用户分组表
+-- 位置:docs/sql/20_rls/user/ak_user_groups_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;消费者端默认不开放直接访问
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_groups ENABLE ROW LEVEL SECURITY;
+
+-- 如果未来消费者端需要展示所在分组,可以在此添加对应的 SELECT 策略
+-- 目前默认不向普通用户开放任何直接 SQL 读写权限
+-- =====================================================================================
+-- RLS: 用户标签表
+-- 位置:docs/sql/20_rls/user/ak_user_labels_rls_v1.sql
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:管理端全量访问通过 RPC 完成;消费者端默认不开放直接访问
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_labels ENABLE ROW LEVEL SECURITY;
+
+-- 若后续消费者端需要展示标签,可在此添加 SELECT 策略
+-- 当前默认不向普通用户开放任何直接 SQL 读写权限
+-- =====================================================================================
+-- RLS: 用户等级表
+-- 位置:docs/sql/20_rls/user/
+-- 对象类型:RLS 策略
+-- 版本:v1
+-- 说明:消费者端可读(仅可见/启用/未删除);管理端全量访问通过 RPC 完成
+-- =====================================================================================
+
+ALTER TABLE public.ak_user_levels ENABLE ROW LEVEL SECURITY;
+
+-- 消费者端:允许读取可见且启用的等级(未删除)
+DROP POLICY IF EXISTS ak_user_levels_public_select_visible_active ON public.ak_user_levels;
+CREATE POLICY ak_user_levels_public_select_visible_active
+ ON public.ak_user_levels
+ FOR SELECT
+ TO anon, authenticated
+ USING (
+ deleted_at IS NULL
+ AND status = 1
+ AND is_visible = TRUE
+ );
+
+-- 默认不开放写权限(INSERT/UPDATE/DELETE)给 anon/authenticated
+
+```
+
+### 4) RPC(关键函数,完整见 30_rpc 目录)
+```sql
+-- =====================================================================================
+-- Admin 统计功能 - 获取全站核心指标概览 RPC
+-- 位置:docs/sql/30_rpc/admin/rpc_admin_get_overall_stats_v1.sql
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:一次性聚合查询销售、订单、用户及商品的核心统计指标
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_overall_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_stats JSONB;
+ v_today_start TIMESTAMPTZ := CURRENT_DATE;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 聚合统计
+ WITH totals AS (
+ SELECT
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1) as total_sales,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1) as total_orders,
+ (SELECT COUNT(*) FROM public.ak_users) as total_users,
+ (SELECT COUNT(*) FROM public.ml_products) as total_products
+ ),
+ today_stats AS (
+ SELECT
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_sales,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_orders,
+ (SELECT COUNT(*) FROM public.ak_users WHERE created_at >= v_today_start) as today_new_users
+ ),
+ pending_tasks AS (
+ SELECT
+ (SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND order_status = 1) as pending_delivery,
+ (SELECT COUNT(*) FROM public.ml_product_skus WHERE stock <= 10) as stock_warning, -- 假设库存小于10为预警
+ (SELECT COUNT(*) FROM public.ml_extract WHERE status = 0) as pending_extract
+ )
+ SELECT jsonb_build_object(
+ 'totals', (SELECT row_to_json(totals.*) FROM totals),
+ 'today', (SELECT row_to_json(today_stats.*) FROM today_stats),
+ 'pending', (SELECT row_to_json(pending_tasks.*) FROM pending_tasks)
+ ) INTO v_stats;
+
+ RETURN v_stats;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_overall_stats() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_overall_stats() TO authenticated;
+-- =====================================================================================
+-- Admin 系统维护 - 获取服务器环境信息 RPC
+-- 位置:docs/sql/30_rpc/admin/rpc_admin_get_system_info_v1.sql
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取服务器操作系统、数据库版本及运行环境信息
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_system_info()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_info JSONB;
+ v_db_version TEXT;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取数据库版本
+ SELECT version() INTO v_db_version;
+
+ -- 3. 构建返回信息
+ v_info := jsonb_build_object(
+ 'server_os', 'Linux (Simulated)', -- 数据库侧通常难以直接获取完整的宿主系统信息
+ 'web_server', 'Nginx/1.24.0 (Simulated)',
+ 'db_engine', 'PostgreSQL',
+ 'db_version', v_db_version,
+ 'uts_runtime', 'uni-app x (UTS)',
+ 'auth_id', 'ZC2884891' -- 模拟授权码
+ );
+
+ RETURN v_info;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_system_info() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_system_info() TO authenticated;
+-- =====================================================================================
+-- Admin 系统功能 - 获取配置项 RPC
+-- 位置:docs/sql/30_rpc/admin/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_system_configs, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_get(
+ p_key TEXT
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_value JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取配置值
+ SELECT config_value INTO v_value
+ FROM public.ml_system_configs
+ WHERE config_key = p_key;
+
+ RETURN v_value;
+END;
+$$;-- =====================================================================================
+-- Admin 系统功能 - 保存/更新配置项 RPC
+-- 位置:docs/sql/30_rpc/admin/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_system_configs, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_save(
+ p_key TEXT,
+ p_value JSONB,
+ p_description TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 插入或更新配置
+ INSERT INTO public.ml_system_configs (config_key, config_value, description, updated_at)
+ VALUES (p_key, p_value, p_description, NOW())
+ ON CONFLICT (config_key) DO UPDATE
+ SET
+ config_value = EXCLUDED.config_value,
+ description = COALESCE(EXCLUDED.description, public.ml_system_configs.description),
+ updated_at = NOW();
+
+ RETURN TRUE;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_analytics_user_gender_distribution
+-- Version: v1
+-- Purpose: 统计指定周期内新增用户的性别分布(用于 Admin/Analytics 图表)
+-- Security: SECURITY DEFINER + 固定 search_path + 入口角色鉴权
+-- Depends: public.ak_users, public.get_current_user_role()
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_analytics_user_gender_distribution(
+ p_start_date DATE,
+ p_end_date DATE
+)
+RETURNS TABLE (
+ name TEXT,
+ value BIGINT
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ IF public.get_current_user_role() NOT IN ('admin', 'analytics') THEN
+ RAISE EXCEPTION 'Permission denied: required role admin or analytics';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ CASE
+ WHEN gender IS NULL OR TRIM(gender::text) = '' THEN '未知'
+ WHEN LOWER(TRIM(gender::text)) = 'male' THEN '男'
+ WHEN LOWER(TRIM(gender::text)) = 'female' THEN '女'
+ WHEN LOWER(TRIM(gender::text)) = 'other' THEN '未知'
+ ELSE '未知'
+ END AS name,
+ COUNT(*)::BIGINT AS value
+ FROM public.ak_users
+ WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
+ GROUP BY 1
+ ORDER BY value DESC;
+END;
+$$;
+-- =====================================================================================
+-- 函数: check_admin_permission
+-- 描述: 通用的 RBAC 权限校验函数
+-- 参数: p_permission_code - 权限编码 (如 'role:delete', 'user:view')
+-- 返回: BOOLEAN
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.check_admin_permission(
+ p_permission_code TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID;
+ v_role TEXT;
+BEGIN
+ -- 1. 获取当前登录用户的 Profile ID 和角色
+ SELECT id, role INTO v_user_id, v_role
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 2. 未登录或未找到 Profile
+ IF v_user_id IS NULL THEN
+ RETURN FALSE;
+ END IF;
+
+ -- 3. 超级管理员拥有所有权限 (保持向下兼容)
+ IF v_role = 'admin' THEN
+ RETURN TRUE;
+ END IF;
+
+ -- 4. 如果指定了权限编码,则检查 ak_permissions 体系
+ IF p_permission_code IS NOT NULL THEN
+ RETURN EXISTS (
+ SELECT 1
+ FROM public.ak_admin_roles ar
+ JOIN public.ak_role_permissions rp ON ar.role_id = rp.role_id
+ JOIN public.ak_permissions p ON rp.permission_id = p.id
+ WHERE ar.admin_id = v_user_id
+ AND p.code = p_permission_code
+ AND p.deleted_at IS NULL
+ AND ar.deleted_at IS NULL
+ );
+ END IF;
+
+ RETURN FALSE;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.check_admin_permission(TEXT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.check_admin_permission(TEXT) TO authenticated;
+-- =====================================================================================
+-- RPC: get_current_user_role
+-- Version: v1
+-- Purpose: 获取当前登录用户的角色(用于 RPC 入口鉴权)
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends: public.ak_users (auth_id, role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.get_current_user_role()
+RETURNS TEXT
+LANGUAGE sql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT role
+ FROM public.ak_users
+ WHERE auth_id = auth.uid()
+ LIMIT 1;
+$$;-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v2
+-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威用户表)并保持 user_roles 兼容写入
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role,created_by) (如存在)
+-- Notes:
+-- - 角色权威口径为 public.ak_users.role
+-- - user_roles 为历史/兼容表:存在则写入,不存在则跳过
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT := 'customer';
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ has_user_roles BOOLEAN := FALSE;
+BEGIN
+ -- 1) 基于邮箱规则分配默认角色(可按需调整)
+ IF user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN
+ user_role := 'teacher';
+ ELSIF user_email ILIKE '%@admin.%' THEN
+ user_role := 'admin';
+ END IF;
+
+ -- 2) 默认 username:取邮箱 @ 前缀
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user';
+ END IF;
+
+ -- 3) 写入 ak_users(权威)
+ -- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ role = COALESCE(public.ak_users.role, EXCLUDED.role),
+ updated_at = now();
+
+ -- 4) 兼容写入 user_roles(如果表存在)
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema='public'
+ AND table_name='user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, user_role, NEW.id);
+ EXCEPTION WHEN unique_violation THEN
+ -- 忽略重复
+ NULL;
+ END;
+ END IF;
+
+ -- 5) 更新 auth.users 元数据(可选保留)
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;
+-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v3
+-- Purpose: auth.users 新用户创建后,同步写入 public.ak_users(权威)和 public.user_roles(兼容)。
+-- 此版本修复了向 user_roles 写入时可能因 role 为 NULL 导致的 NOT NULL 约束失败问题。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT;
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ has_user_roles BOOLEAN := FALSE;
+BEGIN
+ -- 1) 基于邮箱规则分配默认角色(可按需调整)
+ -- 确保 user_role 总有一个非 NULL 的值
+ user_role := CASE
+ WHEN user_email ILIKE '%@admin.%' THEN 'admin'
+ WHEN user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN 'teacher'
+ ELSE 'consumer' -- 默认角色
+ END;
+
+ -- 2) 默认 username:取邮箱 @ 前缀
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user_' || SUBSTRING(NEW.id::text, 1, 8); -- 使用 user_ + uid前8位作为备用名
+ END IF;
+
+ -- 3) 写入 ak_users(权威)
+ -- 使用 ON CONFLICT 确保幂等:同一 auth_id 只会有一条记录
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ -- 只有当现有 role 为空时才更新,避免覆盖手动设置的 admin 角色
+ role = COALESCE(public.ak_users.role, EXCLUDED.role),
+ updated_at = now();
+
+ -- 4) 兼容写入 user_roles(如果表存在)
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema='public'
+ AND table_name='user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ -- 确保插入的 role 不为 NULL,即使上面的逻辑有误
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, COALESCE(user_role, 'customer'), NEW.id);
+ EXCEPTION
+ WHEN unique_violation THEN
+ -- 忽略重复插入的错误
+ NULL;
+ WHEN not_null_violation THEN
+ -- 记录非空约束错误,但不中断整个触发器
+ RAISE NOTICE '[handle_new_user] WARNING: Failed to INSERT into user_roles due to NOT NULL violation. user_id: %, role: %', NEW.id, user_role;
+ END;
+ END IF;
+
+ -- 5) 更新 auth.users 元数据(可选保留)
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;-- =====================================================================================
+-- Trigger Function: handle_new_user
+-- Version: v4
+-- Purpose: auth.users 新用户创建后,优先读取 raw_user_meta_data.user_role 写入 ak_users.role。
+-- 解决 delivery / merchant 注册时被错误降级为默认 consumer 的问题。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends:
+-- - public.ak_users(auth_id,email,username,role)
+-- - public.user_roles(user_id,role)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ user_role TEXT;
+ metadata_role TEXT;
+ user_email TEXT := NEW.email;
+ user_name TEXT;
+ ak_user_id UUID;
+ has_user_roles BOOLEAN := FALSE;
+ has_delivery_staff BOOLEAN := FALSE;
+BEGIN
+ metadata_role := NULLIF(TRIM(COALESCE(NEW.raw_user_meta_data ->> 'user_role', '')), '');
+
+ user_role := CASE
+ WHEN metadata_role IN ('customer', 'merchant', 'delivery', 'service', 'admin') THEN metadata_role
+ WHEN user_email ILIKE '%@admin.%' THEN 'admin'
+ WHEN user_email ILIKE '%@teacher.%' OR user_email ILIKE '%@edu.%' THEN 'customer'
+ ELSE 'customer'
+ END;
+
+ IF user_email IS NOT NULL AND POSITION('@' IN user_email) > 1 THEN
+ user_name := SPLIT_PART(user_email, '@', 1);
+ ELSE
+ user_name := 'user_' || SUBSTRING(NEW.id::text, 1, 8);
+ END IF;
+
+ INSERT INTO public.ak_users (auth_id, email, username, role)
+ VALUES (NEW.id, user_email, user_name, user_role)
+ ON CONFLICT (auth_id)
+ DO UPDATE SET
+ email = COALESCE(EXCLUDED.email, public.ak_users.email),
+ username = COALESCE(EXCLUDED.username, public.ak_users.username),
+ role = COALESCE(NULLIF(public.ak_users.role, ''), EXCLUDED.role),
+ updated_at = now()
+ RETURNING id INTO ak_user_id;
+
+ IF user_role = 'delivery' THEN
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'ml_delivery_staff'
+ ) INTO has_delivery_staff;
+
+ IF has_delivery_staff THEN
+ INSERT INTO public.ml_delivery_staff (
+ uid,
+ nickname,
+ phone,
+ status,
+ is_active
+ )
+ SELECT
+ ak_user_id,
+ user_name,
+ '',
+ 1,
+ TRUE
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff
+ WHERE uid = ak_user_id
+ );
+ END IF;
+ END IF;
+
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'user_roles'
+ ) INTO has_user_roles;
+
+ IF has_user_roles THEN
+ BEGIN
+ INSERT INTO public.user_roles (user_id, role, created_by)
+ VALUES (NEW.id, user_role, NEW.id)
+ ON CONFLICT DO NOTHING;
+ EXCEPTION
+ WHEN check_violation THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Skipped user_roles insert due to check violation. user_id: %, role: %', NEW.id, user_role;
+ WHEN not_null_violation THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Failed to INSERT into user_roles due to NOT NULL violation. user_id: %, role: %', NEW.id, user_role;
+ WHEN others THEN
+ RAISE NOTICE '[handle_new_user_v4] WARNING: Skipped user_roles insert due to unexpected error. user_id: %, role: %, err: %', NEW.id, user_role, SQLERRM;
+ END;
+ END IF;
+
+ UPDATE auth.users
+ SET raw_user_meta_data = COALESCE(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('user_role', user_role)
+ WHERE id = NEW.id;
+
+ RETURN NEW;
+END;
+$$;-- RPC: rpc_admin_delete_permission
+-- 管理端删除功能权限/菜单(支持级联软删除关联的角色权限映射)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_permission(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数,权限编码: permission:delete)
+ IF NOT public.check_admin_permission('permission:delete') THEN
+ RAISE EXCEPTION 'Permission denied: permission:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先删除所有关联了该权限的角色映射
+ UPDATE public.ak_role_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE permission_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 最后软删除权限本身
+ UPDATE public.ak_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_permission(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_permission(UUID) TO authenticated;
+-- RPC: rpc_admin_delete_role
+-- 管理端删除角色(支持级联软删除关联权限)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_role(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数,权限编码: role:delete)
+ IF NOT public.check_admin_permission('role:delete') THEN
+ RAISE EXCEPTION 'Permission denied: role:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id
+ FROM public.ak_users
+ WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先删除该角色下的所有权限关联
+ UPDATE public.ak_role_permissions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE role_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 级联软删除:再删除该角色下的所有管理员关联
+ UPDATE public.ak_admin_roles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE role_id = p_id AND deleted_at IS NULL;
+
+ -- 5. 最后软删除角色本身
+ UPDATE public.ak_roles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_role(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_role(UUID) TO authenticated;
+-- RPC: rpc_admin_get_admin_list
+-- 管理端获取管理员列表
+-- 筛选 ak_users 表中 role 为 'admin' 或 'analytics' 的用户,并关联显示其角色信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_admin_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_users u
+ WHERE u.role IN ('admin', 'analytics')
+ AND (p_status IS NULL OR u.is_active = (p_status = 1))
+ AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据列表 (关联角色)
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ u.id,
+ u.username,
+ u.real_name,
+ u.role,
+ u.is_active,
+ u.last_login_at,
+ u.last_login_ip,
+ (
+ SELECT jsonb_agg(r.name)
+ FROM public.ak_admin_roles ar
+ JOIN public.ak_roles r ON r.id = ar.role_id
+ WHERE ar.user_id = u.id
+ ) as roles
+ FROM public.ak_users u
+ WHERE u.role IN ('admin', 'analytics')
+ AND (p_status IS NULL OR u.is_active = (p_status = 1))
+ AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%')
+ ORDER BY u.created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_get_permission_list
+-- 管理端获取全量权限/菜单列表 (供前端构建树形结构)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_permission_list()
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取全量数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, parent_id, name, code, type,
+ path, icon, sort_order, is_visible,
+ created_at, updated_at
+ FROM public.ak_permissions
+ WHERE deleted_at IS NULL
+ ORDER BY sort_order ASC, created_at ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_permission_list() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_permission_list() TO authenticated;
+-- RPC: rpc_admin_get_role_list
+-- 管理端获取角色分页列表
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_role_list(
+ p_search TEXT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_roles
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, code, description, is_active,
+ created_at, updated_at
+ FROM public.ak_roles
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_save_permission
+-- 管理端新增或更新功能权限/菜单
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_permission(
+ p_id UUID DEFAULT NULL,
+ p_parent_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_code TEXT DEFAULT NULL,
+ p_type TEXT DEFAULT 'menu',
+ p_path TEXT DEFAULT NULL,
+ p_icon TEXT DEFAULT NULL,
+ p_sort_order INTEGER DEFAULT 0,
+ p_is_visible BOOLEAN DEFAULT TRUE
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL OR p_code IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name or code';
+ END IF;
+
+ INSERT INTO public.ak_permissions (
+ parent_id, name, code, type, path, icon, sort_order, is_visible
+ ) VALUES (
+ p_parent_id, p_name, p_code, p_type, p_path, p_icon, p_sort_order, p_is_visible
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ak_permissions
+ SET
+ parent_id = COALESCE(p_parent_id, parent_id),
+ name = COALESCE(p_name, name),
+ code = COALESCE(p_code, code),
+ type = COALESCE(p_type, type),
+ path = COALESCE(p_path, path),
+ icon = COALESCE(p_icon, icon),
+ sort_order = COALESCE(p_sort_order, sort_order),
+ is_visible = COALESCE(p_is_visible, is_visible),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Permission item not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) TO authenticated;
+-- RPC: rpc_admin_save_role
+-- 管理端新增或更新角色
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_role(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_code TEXT DEFAULT NULL,
+ p_description TEXT DEFAULT NULL,
+ p_is_active BOOLEAN DEFAULT TRUE
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL OR p_code IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name or code';
+ END IF;
+
+ INSERT INTO public.ak_roles (
+ name, code, description, is_active
+ ) VALUES (
+ p_name, p_code, p_description, p_is_active
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ak_roles
+ SET
+ name = COALESCE(p_name, name),
+ code = COALESCE(p_code, code),
+ description = COALESCE(p_description, description),
+ is_active = COALESCE(p_is_active, is_active),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Role not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_delete
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除文章分类(支持级联软删除分类下的文章)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('cms:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: cms:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该分类下的所有文章
+ UPDATE public.ml_articles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 软删除分类本身
+ UPDATE public.ml_article_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_delete IS '管理员删除文章分类(级联软删除关联文章)';
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_list
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取文章分类列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_article_categories
+ WHERE (p_search IS NULL OR name ILIKE '%' || p_search || '%');
+
+ -- 3. 获取列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT id, name, icon, sort, status, created_at, updated_at
+ FROM public.ml_article_categories
+ WHERE (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ ORDER BY sort ASC, created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_save
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新文章分类
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_icon TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_article_categories (
+ name, icon, sort, status
+ ) VALUES (
+ p_name, p_icon, p_sort, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 4. 更新
+ UPDATE public.ml_article_categories
+ SET
+ name = p_name,
+ icon = COALESCE(p_icon, icon),
+ sort = p_sort,
+ status = p_status,
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Category not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_save IS '管理员新增或更新文章分类';
+-- =====================================================================================
+-- RPC: rpc_admin_article_category_set_status
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换文章分类启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_category_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_article_categories
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_category_set_status IS '管理员设置文章分类状态';
+-- =====================================================================================
+-- RPC: rpc_admin_article_delete
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除文章记录(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('cms:article:delete') THEN
+ RAISE EXCEPTION 'Permission denied: cms:article:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除
+ UPDATE public.ml_articles
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_delete IS '管理员删除文章记录';
+-- =====================================================================================
+-- RPC: rpc_admin_article_get_detail
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取指定文章的完整详情
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_get_detail(
+ p_id UUID
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_item JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取详情
+ SELECT jsonb_build_object(
+ 'id', a.id,
+ 'category_id', a.category_id,
+ 'category_name', c.name,
+ 'title', a.title,
+ 'author', a.author,
+ 'image', a.image,
+ 'description', a.description,
+ 'content', a.content,
+ 'status', a.status,
+ 'views', a.views,
+ 'is_banner', a.is_banner,
+ 'is_hot', a.is_hot,
+ 'linked_product_id', a.linked_product_id,
+ 'created_at', a.created_at,
+ 'updated_at', a.updated_at
+ ) INTO v_item
+ FROM public.ml_articles a
+ LEFT JOIN public.ml_article_categories c ON c.id = a.category_id
+ WHERE a.id = p_id;
+
+ IF v_item IS NULL THEN
+ RAISE EXCEPTION 'Article not found';
+ END IF;
+
+ RETURN v_item;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_get_detail IS '管理员获取文章完整详情';
+-- =====================================================================================
+-- RPC: rpc_admin_article_list
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取文章列表,支持搜索、分类筛选及状态过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_category_id UUID DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_articles a
+ WHERE (p_category_id IS NULL OR a.category_id = p_category_id)
+ AND (p_status IS NULL OR a.status = p_status)
+ AND (p_search IS NULL OR a.title ILIKE '%' || p_search || '%' OR a.author ILIKE '%' || p_search || '%');
+
+ -- 3. 获取列表数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ a.id,
+ a.category_id,
+ c.name as category_name,
+ a.title,
+ a.author,
+ a.image,
+ a.description,
+ a.status,
+ a.views,
+ a.is_banner,
+ a.is_hot,
+ a.created_at,
+ a.updated_at
+ FROM public.ml_articles a
+ LEFT JOIN public.ml_article_categories c ON c.id = a.category_id
+ WHERE (p_category_id IS NULL OR a.category_id = p_category_id)
+ AND (p_status IS NULL OR a.status = p_status)
+ AND (p_search IS NULL OR a.title ILIKE '%' || p_search || '%' OR a.author ILIKE '%' || p_search || '%')
+ ORDER BY a.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_list IS '管理员分页查询文章列表';
+-- =====================================================================================
+-- RPC: rpc_admin_article_save
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新文章内容
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_save(
+ p_id UUID DEFAULT NULL,
+ p_category_id UUID DEFAULT NULL,
+ p_title TEXT DEFAULT NULL,
+ p_author TEXT DEFAULT NULL,
+ p_image TEXT DEFAULT NULL,
+ p_description TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 0,
+ p_is_banner BOOLEAN DEFAULT FALSE,
+ p_is_hot BOOLEAN DEFAULT FALSE,
+ p_linked_product_id UUID DEFAULT NULL
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_title IS NULL OR length(trim(p_title)) = 0 THEN
+ RAISE EXCEPTION 'Invalid title';
+ END IF;
+ IF p_category_id IS NULL THEN
+ RAISE EXCEPTION 'Category is required';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_articles (
+ category_id, title, author, image, description, content,
+ status, is_banner, is_hot, linked_product_id
+ ) VALUES (
+ p_category_id, p_title, p_author, p_image, p_description, p_content,
+ p_status, p_is_banner, p_is_hot, p_linked_product_id
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 4. 更新
+ UPDATE public.ml_articles
+ SET
+ category_id = COALESCE(p_category_id, category_id),
+ title = COALESCE(p_title, title),
+ author = COALESCE(p_author, author),
+ image = COALESCE(p_image, image),
+ description = COALESCE(p_description, description),
+ content = COALESCE(p_content, content),
+ status = COALESCE(p_status, status),
+ is_banner = COALESCE(p_is_banner, is_banner),
+ is_hot = COALESCE(p_is_hot, is_hot),
+ linked_product_id = p_linked_product_id,
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Article not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_save IS '管理员新增或更新文章内容';
+-- =====================================================================================
+-- RPC: rpc_admin_article_set_status
+-- 位置:docs/sql/30_rpc/cms/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换文章发布/下架状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_article_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_articles
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_article_set_status IS '管理员设置文章发布状态';
+-- RPC: rpc_admin_delete_diy_page
+-- 管理端删除 DIY 页面配置(支持权限检查与首页保护)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_diy_page(
+ p_id uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数)
+ IF NOT public.check_admin_permission('decoration:page:delete') THEN
+ RAISE EXCEPTION 'Permission denied: decoration:page:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 校验:不允许删除当前生效的首页
+ IF EXISTS (
+ SELECT 1 FROM public.ak_diy_pages
+ WHERE id = p_id AND is_home = true AND deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'cannot delete the active home page';
+ END IF;
+
+ -- 4. 执行软删除:标记 deleted_at
+ UPDATE public.ak_diy_pages
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_diy_page(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_diy_page(uuid) TO authenticated;
+-- RPC: rpc_admin_get_diy_page_list
+-- 管理端获取 DIY 页面分页列表
+-- 支持按名称搜索和按类型筛选
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_diy_page_list(
+ p_search text DEFAULT NULL,
+ p_type text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+ v_total bigint;
+ v_items jsonb;
+BEGIN
+ -- 1. 权限检查 (仅管理员或分析员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_diy_pages
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%')
+ AND (p_type IS NULL OR type = p_type);
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, type, is_home, is_active,
+ created_at, updated_at
+ FROM public.ak_diy_pages
+ WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%')
+ AND (p_type IS NULL OR type = p_type)
+ ORDER BY created_at DESC
+ LIMIT v_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回 JSON 结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_save_diy_page
+-- 管理端新增或更新 DIY 页面配置
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_diy_page(
+ p_id uuid DEFAULT NULL,
+ p_name text DEFAULT NULL,
+ p_type text DEFAULT NULL,
+ p_config jsonb DEFAULT '{}'::jsonb,
+ p_is_active boolean DEFAULT true
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id uuid;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_diy_pages (
+ name, type, config, is_active, updated_by, created_by
+ ) VALUES (
+ p_name, p_type, p_config, p_is_active, auth.uid(), auth.uid()
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ak_diy_pages
+ SET
+ name = COALESCE(p_name, name),
+ type = COALESCE(p_type, type),
+ config = COALESCE(p_config, config),
+ is_active = COALESCE(p_is_active, is_active),
+ updated_at = now(),
+ updated_by = auth.uid()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'page not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) TO authenticated;
+-- RPC: rpc_admin_set_home_page
+-- 管理端设置生效首页
+-- 逻辑:先取消所有同类型页面的 is_home 状态,再设置目标页面为 is_home
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_set_home_page(
+ p_id uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_type text;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 2. 获取目标页面类型
+ SELECT type INTO v_type FROM public.ak_diy_pages WHERE id = p_id;
+ IF v_type IS NULL THEN
+ RAISE EXCEPTION 'page not found';
+ END IF;
+
+ -- 3. 原子切换:同一类型的页面只能有一个 is_home
+ UPDATE public.ak_diy_pages SET is_home = false WHERE type = v_type;
+ UPDATE public.ak_diy_pages SET is_home = true WHERE id = p_id;
+
+ RETURN true;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_set_home_page(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_set_home_page(uuid) TO authenticated;
+-- RPC: rpc_admin_delete_delivery_staff
+-- 管理端删除配送员(支持权限检查)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_staff(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查 (使用通用权限校验函数)
+ IF NOT public.check_admin_permission('delivery:staff:delete') THEN
+ RAISE EXCEPTION 'Permission denied: delivery:staff:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_delivery_staff
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) TO authenticated;
+-- RPC: rpc_admin_delete_delivery_station
+-- 管理端删除提货点/核销点(支持级联软删除配送员关联)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_station(
+ p_id UUID
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('delivery:station:delete') THEN
+ RAISE EXCEPTION 'Permission denied: delivery:station:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该站点下的所有配送员
+ UPDATE public.ml_delivery_staff
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE station_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 执行软删除站点本身
+ UPDATE public.ml_delivery_stations
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) TO authenticated;
+-- RPC: rpc_admin_get_delivery_staff_list
+-- 管理端获取配送员分页列表
+-- 支持按姓名或手机号搜索
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_staff_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_staff
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, uid, nickname, avatar, phone, status, is_active,
+ created_at, updated_at
+ FROM public.ml_delivery_staff
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_get_delivery_staff_list
+-- 管理端获取服务人员分页列表(v2)
+
+DROP FUNCTION IF EXISTS public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER);
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_staff_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND (p_status IS NULL OR s.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR s.nickname ILIKE '%' || p_search || '%'
+ OR s.phone ILIKE '%' || p_search || '%'
+ OR s.staff_no ILIKE '%' || p_search || '%'
+ );
+
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ s.id,
+ s.uid,
+ s.station_id,
+ st.name AS station_name,
+ s.staff_no,
+ s.nickname,
+ s.avatar,
+ s.phone,
+ s.status,
+ s.is_active,
+ s.online_status,
+ s.certificate_status,
+ s.certificate_expire_at,
+ s.service_area,
+ s.skills,
+ s.created_at,
+ s.updated_at
+ FROM public.ml_delivery_staff s
+ LEFT JOIN public.ml_delivery_stations st ON st.id = s.station_id AND st.deleted_at IS NULL
+ WHERE s.deleted_at IS NULL
+ AND (p_status IS NULL OR s.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR s.nickname ILIKE '%' || p_search || '%'
+ OR s.phone ILIKE '%' || p_search || '%'
+ OR s.staff_no ILIKE '%' || p_search || '%'
+ )
+ ORDER BY s.created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;-- RPC: rpc_admin_get_delivery_station_list
+-- 管理端获取提货点/核销点分页列表
+-- 支持按名称、地址或手机号搜索
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_station_list(
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL,
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 20
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset INTEGER := (p_page - 1) * p_page_size;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_delivery_stations
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = ''
+ OR name ILIKE '%' || p_search || '%'
+ OR address ILIKE '%' || p_search || '%'
+ OR phone ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, phone, address, image,
+ lng, lat, status, sort_order, business_hours,
+ created_at, updated_at
+ FROM public.ml_delivery_stations
+ WHERE (p_status IS NULL OR status = p_status)
+ AND (p_search IS NULL OR p_search = ''
+ OR name ILIKE '%' || p_search || '%'
+ OR address ILIKE '%' || p_search || '%'
+ OR phone ILIKE '%' || p_search || '%')
+ ORDER BY sort_order ASC, created_at DESC
+ LIMIT p_page_size OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;
+-- RPC: rpc_admin_save_delivery_staff
+-- 管理端新增或更新配送员信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_staff(
+ p_id UUID DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_nickname IS NULL OR p_phone IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: nickname or phone';
+ END IF;
+
+ -- 3. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_staff (
+ nickname, avatar, phone, status
+ ) VALUES (
+ p_nickname, p_avatar, p_phone, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_staff
+ SET
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ phone = COALESCE(p_phone, phone),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Delivery staff not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) TO authenticated;
+-- RPC: rpc_admin_save_delivery_staff
+-- 管理端新增或更新服务人员信息(v2)
+
+DROP FUNCTION IF EXISTS public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT);
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_staff(
+ p_id UUID DEFAULT NULL,
+ p_uid UUID DEFAULT NULL,
+ p_station_id UUID DEFAULT NULL,
+ p_staff_no TEXT DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1,
+ p_online_status TEXT DEFAULT 'resting',
+ p_certificate_status TEXT DEFAULT 'pending',
+ p_certificate_expire_at DATE DEFAULT NULL,
+ p_service_area TEXT DEFAULT '',
+ p_skills JSONB DEFAULT '[]'::jsonb
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ IF p_nickname IS NULL OR p_phone IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: nickname or phone';
+ END IF;
+
+ IF p_online_status NOT IN ('online', 'resting', 'busy') THEN
+ RAISE EXCEPTION 'Invalid online_status';
+ END IF;
+
+ IF p_certificate_status NOT IN ('valid', 'expired', 'pending') THEN
+ RAISE EXCEPTION 'Invalid certificate_status';
+ END IF;
+
+ IF p_station_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_stations s
+ WHERE s.id = p_station_id AND s.deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'Delivery station not found';
+ END IF;
+
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_staff (
+ uid, station_id, staff_no, nickname, avatar, phone, status,
+ online_status, certificate_status, certificate_expire_at,
+ service_area, skills
+ ) VALUES (
+ p_uid, p_station_id, NULLIF(p_staff_no, ''), p_nickname, p_avatar, p_phone, p_status,
+ p_online_status, p_certificate_status, p_certificate_expire_at,
+ COALESCE(p_service_area, ''), COALESCE(p_skills, '[]'::jsonb)
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_staff
+ SET
+ uid = COALESCE(p_uid, uid),
+ station_id = p_station_id,
+ staff_no = CASE WHEN p_staff_no IS NULL OR p_staff_no = '' THEN NULL ELSE p_staff_no END,
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ phone = COALESCE(p_phone, phone),
+ status = COALESCE(p_status, status),
+ online_status = COALESCE(p_online_status, online_status),
+ certificate_status = COALESCE(p_certificate_status, certificate_status),
+ certificate_expire_at = p_certificate_expire_at,
+ service_area = COALESCE(p_service_area, service_area),
+ skills = COALESCE(p_skills, skills),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Delivery staff not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, UUID, UUID, TEXT, TEXT, TEXT, TEXT, SMALLINT, TEXT, TEXT, DATE, TEXT, JSONB) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, UUID, UUID, TEXT, TEXT, TEXT, TEXT, SMALLINT, TEXT, TEXT, DATE, TEXT, JSONB) TO authenticated;-- RPC: rpc_admin_save_delivery_station
+-- 管理端新增或更新提货点/核销点信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_station(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_phone TEXT DEFAULT NULL,
+ p_address TEXT DEFAULT NULL,
+ p_image TEXT DEFAULT NULL,
+ p_lng NUMERIC DEFAULT NULL,
+ p_lat NUMERIC DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1,
+ p_sort_order INTEGER DEFAULT 0,
+ p_business_hours JSONB DEFAULT NULL
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查 (仅管理员)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR p_phone IS NULL OR p_address IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name, phone or address';
+ END IF;
+
+ -- 3. 新增或更新
+ IF p_id IS NULL THEN
+ INSERT INTO public.ml_delivery_stations (
+ name, phone, address, image, lng, lat, status, sort_order, business_hours
+ ) VALUES (
+ p_name, p_phone, p_address, p_image, p_lng, p_lat, p_status, p_sort_order, p_business_hours
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE public.ml_delivery_stations
+ SET
+ name = COALESCE(p_name, name),
+ phone = COALESCE(p_phone, phone),
+ address = COALESCE(p_address, address),
+ image = COALESCE(p_image, image),
+ lng = COALESCE(p_lng, lng),
+ lat = COALESCE(p_lat, lat),
+ status = COALESCE(p_status, status),
+ sort_order = COALESCE(p_sort_order, sort_order),
+ business_hours = COALESCE(p_business_hours, business_hours),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Station not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) TO authenticated;
+-- =====================================================================================
+-- RPC: rpc_delivery_* homecare actions
+-- Version: v1
+-- Purpose: 为 delivery 页面当前 api/delivery.uts 已固定的 rpc_delivery_* 契约补齐 SQL 端实现。
+-- Notes:
+-- 1. 优先兼容 ec/hc 新链,检测不到表或执行失败时回退 hss 旧链。
+-- 2. 返回 JSON 结构直接对齐 delivery/types/delivery.uts。
+-- 3. message_list 与 record_list 采用最小可用实现,避免前端继续落入 fallback。
+-- =====================================================================================
+
+-- 迁移正文见同批文件:mall_sql/migrations/20260526_delivery_homecare_rpc_v1.sql-- =====================================================================================
+-- RPC: rpc_homecare_dispatch_candidate
+-- Version: v1
+-- Purpose: 为居家上门服务返回单个可派单候选人,替代公开可派单人员 RLS。
+-- Security: SECURITY DEFINER + 固定 search_path
+-- Depends: public.ak_users, public.ml_delivery_staff
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_homecare_dispatch_candidate(
+ p_service_code TEXT DEFAULT NULL,
+ p_station_id UUID DEFAULT NULL
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_candidate JSONB;
+BEGIN
+ IF auth.uid() IS NULL OR NOT EXISTS (
+ SELECT 1
+ FROM public.ak_users u
+ WHERE u.auth_id = auth.uid()
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ SELECT jsonb_build_object(
+ 'id', s.id,
+ 'uid', s.uid,
+ 'station_id', s.station_id,
+ 'status', s.status,
+ 'online_status', s.online_status,
+ 'updated_at', s.updated_at,
+ 'created_at', s.created_at
+ )
+ INTO v_candidate
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ AND (p_station_id IS NULL OR s.station_id = p_station_id)
+ ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.created_at DESC
+ LIMIT 1;
+
+ RETURN v_candidate;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_homecare_dispatch_candidate(TEXT, UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_homecare_dispatch_candidate(TEXT, UUID) TO authenticated;-- RPC: rpc_admin_delete_agent
+-- 管理端删除代理商(支持级联软删除代理申请记录)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_agent(
+ p_uid uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('distribution:agent:delete') THEN
+ RAISE EXCEPTION 'Permission denied: distribution:agent:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该代理的所有申请记录
+ UPDATE public.ak_distribution_agent_applications
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE user_id = p_uid AND deleted_at IS NULL;
+
+ -- 4. 软删除代理商记录
+ UPDATE public.ak_distribution_agents
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE uid = p_uid AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_agent(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_agent(uuid) TO authenticated;
+-- RPC: rpc_admin_delete_division
+-- 管理端删除事业部(支持级联软删除关联代理)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_delete_division(
+ p_uid uuid
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ok boolean;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('distribution:division:delete') THEN
+ RAISE EXCEPTION 'Permission denied: distribution:division:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:该事业部下的所有代理商
+ UPDATE public.ak_distribution_agents
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE division_uid = p_uid AND deleted_at IS NULL;
+
+ -- 4. 级联软删除:该事业部的所有申请记录
+ UPDATE public.ak_distribution_division_applications
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE user_id = p_uid AND deleted_at IS NULL;
+
+ -- 5. 软删除事业部本身
+ UPDATE public.ak_distribution_divisions
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE uid = p_uid AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_delete_division(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_division(uuid) TO authenticated;
+-- RPC: rpc_admin_get_agent_apply_list
+-- 管理端获取代理商申请列表
+-- 支持按状态过滤:all, pending, approved, rejected
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_apply_list(
+ p_status text DEFAULT 'all',
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ id uuid,
+ uid uuid,
+ name text,
+ phone text,
+ dept_uid uuid,
+ dept_name text,
+ proof_images jsonb,
+ status text,
+ refusal_reason text,
+ time timestamptz,
+ invite_code text
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ a.id,
+ a.uid,
+ a.agent_name AS name,
+ a.agent_phone AS phone,
+ a.division_uid AS dept_uid,
+ d.name AS dept_name,
+ a.proof_images,
+ a.status,
+ a.refusal_reason,
+ a.created_at AS time,
+ d.invite_code
+ FROM public.ak_distribution_agent_applications a
+ JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid
+ WHERE (p_status = 'all' OR a.status = p_status)
+ AND (
+ p_search IS NULL OR p_search = ''
+ OR a.agent_name ILIKE ('%' || p_search || '%')
+ OR a.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY a.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_agent_list
+-- 管理端获取代理商列表
+-- 支持搜索代理商名称或负责人UID,并关联显示所属事业部信息
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ uid uuid,
+ name text,
+ division_uid uuid,
+ division_name text,
+ commission_ratio numeric,
+ is_enabled boolean,
+ end_time timestamptz,
+ created_at timestamptz,
+ "staffCount" bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ a.uid,
+ a.name,
+ a.division_uid,
+ d.name AS division_name,
+ a.commission_ratio,
+ a.is_enabled,
+ a.end_time,
+ a.created_at,
+ (SELECT COUNT(*) FROM public.ak_promoter_relations r WHERE r.inviter_uid = a.uid)::bigint AS "staffCount"
+ FROM public.ak_distribution_agents a
+ JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR a.name ILIKE ('%' || p_search || '%')
+ OR a.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY a.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_division_list
+-- 管理端获取事业部列表
+-- 支持搜索事业部名称或负责人UID
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_division_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ uid uuid,
+ name text,
+ invite_code text,
+ commission_ratio numeric,
+ is_enabled boolean,
+ end_time timestamptz,
+ created_at timestamptz,
+ "agentCount" bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 仅管理员或分析员可调用
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ d.uid,
+ d.name,
+ d.invite_code,
+ d.commission_ratio,
+ d.is_enabled,
+ d.end_time,
+ d.created_at,
+ (SELECT COUNT(*) FROM public.ak_distribution_agents a WHERE a.division_uid = d.uid)::bigint AS "agentCount"
+ FROM public.ak_distribution_divisions d
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR d.name ILIKE ('%' || p_search || '%')
+ OR d.uid::text ILIKE ('%' || p_search || '%')
+ )
+ ORDER BY d.created_at DESC
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) TO authenticated;
+-- RPC: rpc_admin_get_promoter_list
+-- 管理端推广员列表聚合统计
+-- 口径:集合=B(上级+下级都算)=> 关系表中出现过的 uid/inviter_uid 都算推广员候选
+-- 统计:
+-- - 推广用户数量:以该用户作为 inviter_uid 的下级人数
+-- - 推广订单数量/金额:其下级用户在 ml_orders 中已完成(order_status=4)的订单数与 paid_amount 汇总
+-- - 佣金:从 ak_commission_logs 聚合
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_promoter_list(
+ p_search text DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20,
+ p_start_time timestamptz DEFAULT NULL,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS TABLE (
+ id uuid,
+ nickname text,
+ name text,
+ phone text,
+ avatar_url text,
+ level text,
+ "userCount" bigint,
+ "orderCount" bigint,
+ "orderAmount" numeric,
+ "commissionTotal" numeric,
+ "withdrawnAmount" numeric,
+ "withdrawCount" bigint,
+ "unwithdrawnAmount" numeric
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_page integer := GREATEST(1, COALESCE(p_page, 1));
+ v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20)));
+ v_offset integer := (v_page - 1) * v_page_size;
+BEGIN
+ -- 仅管理员可调用
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ RETURN QUERY
+ WITH promoters AS (
+ SELECT DISTINCT x.uid
+ FROM (
+ SELECT r.uid FROM public.ak_promoter_relations r
+ UNION
+ SELECT r.inviter_uid FROM public.ak_promoter_relations r
+ ) x
+ ),
+ base AS (
+ SELECT
+ u.id,
+ u.username AS nickname,
+ u.real_name AS name,
+ u.phone,
+ u.avatar_url,
+ u.role AS level
+ FROM promoters p
+ JOIN public.ak_users u ON u.id = p.uid
+ WHERE (
+ p_search IS NULL OR p_search = ''
+ OR u.username ILIKE ('%' || p_search || '%')
+ OR COALESCE(u.real_name, '') ILIKE ('%' || p_search || '%')
+ OR COALESCE(u.phone, '') ILIKE ('%' || p_search || '%')
+ OR u.id::text ILIKE ('%' || p_search || '%')
+ )
+ ),
+ downline AS (
+ SELECT inviter_uid, uid
+ FROM public.ak_promoter_relations
+ ),
+ user_stats AS (
+ SELECT
+ d.inviter_uid AS id,
+ COUNT(*)::bigint AS "userCount"
+ FROM downline d
+ GROUP BY d.inviter_uid
+ ),
+ order_stats AS (
+ SELECT
+ d.inviter_uid AS id,
+ COUNT(o.id)::bigint AS "orderCount",
+ COALESCE(SUM(o.paid_amount), 0)::numeric AS "orderAmount"
+ FROM downline d
+ JOIN public.ml_orders o ON o.user_id = d.uid
+ WHERE o.order_status = 4
+ AND (p_start_time IS NULL OR o.completed_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.completed_at <= p_end_time)
+ GROUP BY d.inviter_uid
+ ),
+ commission_stats AS (
+ SELECT
+ c.uid AS id,
+ COALESCE(SUM(c.amount), 0)::numeric AS "commissionTotal",
+ COALESCE(SUM(CASE WHEN c.status = 'withdrawn' THEN c.amount ELSE 0 END), 0)::numeric AS "withdrawnAmount",
+ 0::bigint AS "withdrawCount",
+ COALESCE(SUM(CASE WHEN c.status IN ('frozen','available') THEN c.amount ELSE 0 END), 0)::numeric AS "unwithdrawnAmount"
+ FROM public.ak_commission_logs c
+ GROUP BY c.uid
+ )
+ SELECT
+ b.id,
+ b.nickname,
+ b.name,
+ b.phone,
+ b.avatar_url,
+ b.level,
+ COALESCE(us."userCount", 0) AS "userCount",
+ COALESCE(os."orderCount", 0) AS "orderCount",
+ COALESCE(os."orderAmount", 0) AS "orderAmount",
+ COALESCE(cs."commissionTotal", 0) AS "commissionTotal",
+ COALESCE(cs."withdrawnAmount", 0) AS "withdrawnAmount",
+ COALESCE(cs."withdrawCount", 0) AS "withdrawCount",
+ COALESCE(cs."unwithdrawnAmount", 0) AS "unwithdrawnAmount"
+ FROM base b
+ LEFT JOIN user_stats us ON us.id = b.id
+ LEFT JOIN order_stats os ON os.id = b.id
+ LEFT JOIN commission_stats cs ON cs.id = b.id
+ ORDER BY b.id
+ LIMIT v_page_size OFFSET v_offset;
+END;
+$$;
+
+-- 授权:仅允许 authenticated 调用,函数内部再做 admin 校验
+REVOKE ALL ON FUNCTION public.rpc_admin_get_promoter_list(text, integer, integer, timestamptz, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_promoter_list(text, integer, integer, timestamptz, timestamptz) TO authenticated;
+-- RPC: rpc_admin_process_agent_apply
+-- 管理端审核代理商申请
+-- 若通过(approved),则同步在 ak_distribution_agents 中创建或更新记录
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_process_agent_apply(
+ p_id uuid,
+ p_status text, -- approved / rejected
+ p_refusal_reason text DEFAULT NULL
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_uid uuid;
+ v_division_uid uuid;
+ v_agent_name text;
+BEGIN
+ -- 仅管理员可审核
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 1. 获取并锁定申请记录
+ SELECT uid, division_uid, agent_name
+ INTO v_uid, v_division_uid, v_agent_name
+ FROM public.ak_distribution_agent_applications
+ WHERE id = p_id;
+
+ IF v_uid IS NULL THEN
+ RAISE EXCEPTION 'application record not found';
+ END IF;
+
+ -- 2. 更新申请状态
+ UPDATE public.ak_distribution_agent_applications
+ SET
+ status = p_status,
+ refusal_reason = CASE WHEN p_status = 'rejected' THEN p_refusal_reason ELSE NULL END,
+ approved_at = now(),
+ approved_by = auth.uid(),
+ updated_at = now()
+ WHERE id = p_id;
+
+ -- 3. 如果通过,则同步到代理商正式表
+ IF p_status = 'approved' THEN
+ INSERT INTO public.ak_distribution_agents (
+ uid, division_uid, name, commission_ratio, is_enabled, updated_at, updated_by
+ )
+ VALUES (
+ v_uid, v_division_uid, v_agent_name, 0, true, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ division_uid = EXCLUDED.division_uid,
+ name = EXCLUDED.name,
+ updated_at = now(),
+ updated_by = auth.uid();
+ END IF;
+
+ RETURN true;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) TO authenticated;
+-- RPC: rpc_admin_save_agent
+-- 管理端新增或更新代理商
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_agent(
+ p_uid uuid,
+ p_division_uid uuid,
+ p_name text,
+ p_commission_ratio numeric,
+ p_is_enabled boolean DEFAULT true,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ -- 仅管理员可操作
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ -- 确保事业部存在
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_distribution_divisions WHERE uid = p_division_uid
+ ) THEN
+ RAISE EXCEPTION 'parent division not found';
+ END IF;
+
+ INSERT INTO public.ak_distribution_agents (
+ uid, division_uid, name, commission_ratio, is_enabled, end_time, updated_at, updated_by
+ )
+ VALUES (
+ p_uid, p_division_uid, p_name, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ division_uid = EXCLUDED.division_uid,
+ name = EXCLUDED.name,
+ commission_ratio = EXCLUDED.commission_ratio,
+ is_enabled = EXCLUDED.is_enabled,
+ end_time = EXCLUDED.end_time,
+ updated_at = now(),
+ updated_by = auth.uid();
+
+ RETURN p_uid;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) TO authenticated;
+-- RPC: rpc_admin_save_division
+-- 管理端新增或更新事业部
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_save_division(
+ p_uid uuid,
+ p_name text,
+ p_invite_code text,
+ p_commission_ratio numeric,
+ p_is_enabled boolean DEFAULT true,
+ p_end_time timestamptz DEFAULT NULL
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ -- 仅管理员可操作
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users u
+ WHERE u.id = auth.uid() AND u.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'permission denied';
+ END IF;
+
+ INSERT INTO public.ak_distribution_divisions (
+ uid, name, invite_code, commission_ratio, is_enabled, end_time, updated_at, updated_by
+ )
+ VALUES (
+ p_uid, p_name, p_invite_code, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid()
+ )
+ ON CONFLICT (uid) DO UPDATE
+ SET
+ name = EXCLUDED.name,
+ invite_code = EXCLUDED.invite_code,
+ commission_ratio = EXCLUDED.commission_ratio,
+ is_enabled = EXCLUDED.is_enabled,
+ end_time = EXCLUDED.end_time,
+ updated_at = now(),
+ updated_by = auth.uid();
+
+ RETURN p_uid;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) TO authenticated;
+-- =====================================================================================
+-- Admin 财务统计 - 余额收支分布统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按业务子类型统计指定时间范围内的余额收入与支出分布
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_distribution(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_income DECIMAL(12,2);
+ v_total_expense DECIMAL(12,2);
+ v_income_items JSONB;
+ v_expense_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算总收入与总支出
+ SELECT
+ COALESCE(SUM(number) FILTER (WHERE pm = 1), 0),
+ COALESCE(SUM(number) FILTER (WHERE pm = 0), 0)
+ INTO v_total_income, v_total_expense
+ FROM public.ml_user_bill
+ WHERE category = 'balance'
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND status = 1;
+
+ -- 3. 统计收入分布 (来源分析)
+ SELECT jsonb_agg(t) INTO v_income_items
+ FROM (
+ SELECT
+ type AS name,
+ SUM(number) AS value,
+ CASE WHEN v_total_income > 0 THEN ROUND(SUM(number) / v_total_income * 100, 2) ELSE 0 END AS percent
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND pm = 1 AND status = 1
+ AND created_at >= p_start_time AND created_at <= p_end_time
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ -- 4. 统计支出分布 (消耗分析)
+ SELECT jsonb_agg(t) INTO v_expense_items
+ FROM (
+ SELECT
+ type AS name,
+ SUM(number) AS value,
+ CASE WHEN v_total_expense > 0 THEN ROUND(SUM(number) / v_total_expense * 100, 2) ELSE 0 END AS percent
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND pm = 0 AND status = 1
+ AND created_at >= p_start_time AND created_at <= p_end_time
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'income', COALESCE(v_income_items, '[]'::jsonb),
+ 'expense', COALESCE(v_expense_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_distribution IS '统计财务余额收支来源与消耗分布';
+-- =====================================================================================
+-- Admin 财务统计 - 余额核心指标 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取全站当前余额存量、累计增加总额及累计消耗总额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_current_balance DECIMAL(12,2);
+ v_total_accumulation DECIMAL(12,2);
+ v_total_consumption DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计当前全站用户余额总存量
+ SELECT COALESCE(SUM(now_money), 0) INTO v_current_balance FROM public.ak_users;
+
+ -- 3. 统计累计增加 (pm=1) 和 累计消耗 (pm=0)
+ -- 基于 ml_user_bill 表中 category='balance' 的记录
+ SELECT
+ COALESCE(SUM(number) FILTER (WHERE pm = 1), 0),
+ COALESCE(SUM(number) FILTER (WHERE pm = 0), 0)
+ INTO v_total_accumulation, v_total_consumption
+ FROM public.ml_user_bill
+ WHERE category = 'balance' AND status = 1;
+
+ RETURN jsonb_build_object(
+ 'current_balance', v_current_balance,
+ 'total_accumulation', v_total_accumulation,
+ 'total_consumption', v_total_consumption
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_stats IS '获取全站余额存量及累计收支汇总';
+-- =====================================================================================
+-- Admin 财务统计 - 余额收支趋势 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按日聚合指定时间范围内的余额积累 (pm=1) 与 余额消耗 (pm=0) 趋势
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_balance_trend(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按日聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'YYYY-MM-DD') AS date_group,
+ COALESCE(SUM(number) FILTER (WHERE pm = 1 AND category = 'balance'), 0) AS accumulation,
+ COALESCE(SUM(number) FILTER (WHERE pm = 0 AND category = 'balance'), 0) AS consumption
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ LEFT JOIN public.ml_user_bill b ON date_trunc('day', b.created_at) = gs.day AND b.status = 1
+ GROUP BY gs.day
+ ORDER BY gs.day ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_balance_trend IS '按日聚合财务余额收支趋势';
+-- =====================================================================================
+-- Admin 财务功能 - 提现申请列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_extract, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_extract_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_status SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_extract e
+ LEFT JOIN public.ak_users u ON u.id = e.uid
+ WHERE (p_status IS NULL OR e.status = p_status)
+ AND (p_start_time IS NULL OR e.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR e.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.real_name, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.bank_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.alipay_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.wechat_code, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ e.id,
+ e.uid,
+ e.real_name,
+ e.extract_type,
+ e.bank_code,
+ e.bank_address,
+ e.alipay_code,
+ e.wechat_code,
+ e.extract_price,
+ e.service_fee,
+ e.balance,
+ e.status,
+ e.refusal_reason,
+ e.admin_id,
+ e.payment_time,
+ e.created_at,
+ e.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_extract e
+ LEFT JOIN public.ak_users u ON u.id = e.uid
+ WHERE (p_status IS NULL OR e.status = p_status)
+ AND (p_start_time IS NULL OR e.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR e.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.real_name, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.bank_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.alipay_code, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(e.wechat_code, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY e.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_extract_list IS '管理员提现申请列表分页查询';
+-- =====================================================================================
+-- Admin 财务功能 - 提现审核 RPC (口径 2)
+-- 位置:docs/sql/30_rpc/finance/
+-- 版本:v1
+-- 描述:提现审核通过时才扣除佣金并生成流水。
+-- 安全策略:SECURITY DEFINER, 入口鉴权, 固定 search_path
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_extract_review(
+ p_extract_id UUID,
+ p_status SMALLINT, -- 1: 通过, -1: 驳回
+ p_refusal_reason TEXT DEFAULT NULL
+)
+RETURNS VOID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_extract RECORD;
+ v_user RECORD;
+BEGIN
+ -- 1. 鉴权:仅 admin 角色可执行
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 锁定并获取提现记录
+ SELECT * INTO v_extract FROM public.ml_extract WHERE id = p_extract_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'Extract record not found'; END IF;
+ IF v_extract.status != 0 THEN RAISE EXCEPTION 'Record already processed'; END IF;
+
+ -- 3. 业务处理
+ IF p_status = 1 THEN
+ -- 审核通过:锁定并校验用户资金
+ SELECT * INTO v_user FROM public.ak_users WHERE id = v_extract.uid FOR UPDATE;
+ IF v_user.brokerage_price < v_extract.extract_price THEN
+ RAISE EXCEPTION 'Insufficient brokerage balance';
+ END IF;
+
+ -- 扣除佣金
+ UPDATE public.ak_users
+ SET brokerage_price = brokerage_price - v_extract.extract_price
+ WHERE id = v_extract.uid;
+
+ -- 写入资金流水
+ INSERT INTO public.ml_user_bill (uid, link_id, pm, title, category, type, number, balance)
+ VALUES (
+ v_extract.uid,
+ p_extract_id::TEXT,
+ 0, -- 支出
+ '佣金提现',
+ 'brokerage',
+ 'extract',
+ v_extract.extract_price,
+ v_user.brokerage_price - v_extract.extract_price
+ );
+
+ -- 更新提现记录
+ UPDATE public.ml_extract
+ SET status = 1, admin_id = auth.uid(), payment_time = now()
+ WHERE id = p_extract_id;
+
+ ELSIF p_status = -1 THEN
+ -- 审核驳回:仅更新状态
+ UPDATE public.ml_extract
+ SET status = -1, refusal_reason = p_refusal_reason, admin_id = auth.uid()
+ WHERE id = p_extract_id;
+ ELSE
+ RAISE EXCEPTION 'Invalid status';
+ END IF;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_extract_review IS '管理员审核提现申请(口径 2:通过时扣款)';
+-- =====================================================================================
+-- Admin 财务功能 - 账单汇总统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按日/周/月维度聚合财务收支数据,支撑账单列表展示
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_finance_bill_summary(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE,
+ p_interval TEXT DEFAULT 'day' -- day, week, month
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc(p_interval, created_at),
+ CASE
+ WHEN p_interval = 'day' THEN 'YYYY-MM-DD'
+ WHEN p_interval = 'week' THEN 'IYYY-IW'
+ ELSE 'YYYY-MM'
+ END
+ ) AS date_group,
+ SUM(number) FILTER (WHERE pm = 1) AS income,
+ SUM(number) FILTER (WHERE pm = 0) AS expense,
+ SUM(CASE WHEN pm = 1 THEN number ELSE -number END) AS net_entry
+ FROM public.ml_user_bill
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND status = 1
+ GROUP BY date_trunc(p_interval, created_at)
+ ORDER BY date_trunc(p_interval, created_at) DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_finance_bill_summary IS '按周期聚合财务收支账单';
+-- =====================================================================================
+-- Admin 财务功能 - 财务概况统计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取指定时间段内的财务核心 KPI(营业额、充值汇总、提现汇总、资金存量)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_finance_overview(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_recharge_amount DECIMAL(12,2);
+ v_recharge_count BIGINT;
+ v_extract_amount DECIMAL(12,2);
+ v_extract_count BIGINT;
+ v_total_user_balance DECIMAL(12,2);
+ v_total_user_brokerage DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计充值 (仅统计已支付)
+ SELECT
+ COALESCE(SUM(price + give_price), 0),
+ COUNT(*)
+ INTO v_recharge_amount, v_recharge_count
+ FROM public.ml_user_recharge
+ WHERE paid = 1
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time;
+
+ -- 3. 统计提现 (仅统计已通过)
+ SELECT
+ COALESCE(SUM(extract_price), 0),
+ COUNT(*)
+ INTO v_extract_amount, v_extract_count
+ FROM public.ml_extract
+ WHERE status = 1
+ AND created_at >= p_start_time
+ AND created_at <= p_end_time;
+
+ -- 4. 统计全站资金存量 (实时快照)
+ SELECT
+ COALESCE(SUM(now_money), 0),
+ COALESCE(SUM(brokerage_price), 0)
+ INTO v_total_user_balance, v_total_user_brokerage
+ FROM public.ak_users;
+
+ RETURN jsonb_build_object(
+ 'recharge_amount', v_recharge_amount,
+ 'recharge_count', v_recharge_count,
+ 'extract_amount', v_extract_amount,
+ 'extract_count', v_extract_count,
+ 'total_user_balance', v_total_user_balance,
+ 'total_user_brokerage', v_total_user_brokerage
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_finance_overview IS '财务核心 KPI 概况统计';
+-- =====================================================================================
+-- RPC: rpc_admin_invoice_list
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取发票申请列表,支持搜索、状态筛选及时间过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_invoice_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_status SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_invoices i
+ LEFT JOIN public.ak_users u ON u.id = i.uid
+ WHERE (p_status IS NULL OR i.status = p_status)
+ AND (p_start_time IS NULL OR i.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR i.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ i.order_no ILIKE '%' || p_search || '%' OR
+ i.header_name ILIKE '%' || p_search || '%' OR
+ u.username ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ i.id,
+ i.uid,
+ i.order_no,
+ i.order_amount,
+ i.invoice_type,
+ i.header_type,
+ i.header_name,
+ i.tax_id,
+ i.email,
+ i.remark,
+ i.status,
+ i.refusal_reason,
+ i.invoice_url,
+ i.created_at,
+ i.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_invoices i
+ LEFT JOIN public.ak_users u ON u.id = i.uid
+ WHERE (p_status IS NULL OR i.status = p_status)
+ AND (p_start_time IS NULL OR i.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR i.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ i.order_no ILIKE '%' || p_search || '%' OR
+ i.header_name ILIKE '%' || p_search || '%' OR
+ u.username ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY i.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_invoice_list IS '管理员分页查询发票申请列表';
+-- =====================================================================================
+-- RPC: rpc_admin_invoice_process
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端处理发票申请(开票或驳回)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_invoice_process(
+ p_id UUID,
+ p_status SMALLINT, -- 1: 已开票, -1: 已拒绝
+ p_invoice_url TEXT DEFAULT NULL,
+ p_refusal_reason TEXT DEFAULT NULL
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_invoices
+ SET
+ status = p_status,
+ invoice_url = CASE WHEN p_status = 1 THEN p_invoice_url ELSE invoice_url END,
+ refusal_reason = CASE WHEN p_status = -1 THEN p_refusal_reason ELSE refusal_reason END,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_invoice_process IS '管理员处理发票开票申请';
+-- =====================================================================================
+-- Admin 财务功能 - 充值补单/审计 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 版本:v1
+-- 描述:由管理员发起的人工充值补单或离线支付审计确认。
+-- 安全策略:SECURITY DEFINER, 入口鉴权, 固定 search_path
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_recharge_audit(
+ p_recharge_id UUID,
+ p_mark TEXT DEFAULT '管理员人工审计/补单'
+)
+RETURNS VOID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_recharge RECORD;
+ v_user RECORD;
+BEGIN
+ -- 1. 鉴权:仅 admin 角色可执行
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 锁定并获取充值记录
+ SELECT * INTO v_recharge FROM public.ml_user_recharge WHERE id = p_recharge_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'Recharge record not found'; END IF;
+ IF v_recharge.paid = 1 THEN RAISE EXCEPTION 'Recharge already paid'; END IF;
+
+ -- 3. 锁定并更新用户余额
+ SELECT * INTO v_user FROM public.ak_users WHERE id = v_recharge.uid FOR UPDATE;
+
+ UPDATE public.ak_users
+ SET now_money = now_money + v_recharge.price + v_recharge.give_price
+ WHERE id = v_recharge.uid;
+
+ -- 4. 写入资金流水
+ INSERT INTO public.ml_user_bill (uid, link_id, pm, title, category, type, number, balance, mark)
+ VALUES (
+ v_recharge.uid,
+ v_recharge.order_no,
+ 1, -- 收入
+ '用户充值',
+ 'now_money',
+ 'recharge',
+ v_recharge.price + v_recharge.give_price,
+ v_user.now_money + v_recharge.price + v_recharge.give_price,
+ p_mark
+ );
+
+ -- 5. 更新充值记录状态
+ UPDATE public.ml_user_recharge
+ SET paid = 1, pay_time = now()
+ WHERE id = p_recharge_id;
+
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_recharge_audit IS '管理员人工审计/补单(更新用户余额并生成流水)';
+-- =====================================================================================
+-- Admin 财务功能 - 充值记录列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_user_recharge, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_recharge_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_paid SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_user_recharge r
+ LEFT JOIN public.ak_users u ON u.id = r.uid
+ WHERE (p_paid IS NULL OR r.paid = p_paid)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(r.order_no, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ r.id,
+ r.uid,
+ r.order_no,
+ r.recharge_type,
+ r.price,
+ r.give_price,
+ r.paid,
+ r.pay_time,
+ r.channel_trade_no,
+ r.status,
+ r.created_at,
+ r.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_user_recharge r
+ LEFT JOIN public.ak_users u ON u.id = r.uid
+ WHERE (p_paid IS NULL OR r.paid = p_paid)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(r.order_no, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY r.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_recharge_list IS '管理员充值记录列表分页查询';
+-- =====================================================================================
+-- Admin 财务功能 - 资金流水列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/finance/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_user_bill, ak_users 表已存在
+-- 权限:仅 admin 角色可执行(口径 A:全局数据访问通过 RPC)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_bill_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_category VARCHAR DEFAULT NULL,
+ p_type VARCHAR DEFAULT NULL,
+ p_pm SMALLINT DEFAULT NULL,
+ p_start_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_end_time TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_user_bill b
+ LEFT JOIN public.ak_users u ON u.id = b.uid
+ WHERE (p_category IS NULL OR b.category = p_category)
+ AND (p_type IS NULL OR b.type = p_type)
+ AND (p_pm IS NULL OR b.pm = p_pm)
+ AND (p_start_time IS NULL OR b.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR b.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(b.title, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ b.id,
+ b.uid,
+ b.link_id,
+ b.pm,
+ b.title,
+ b.category,
+ b.type,
+ b.number,
+ b.balance,
+ b.mark,
+ b.status,
+ b.created_at,
+ b.updated_at,
+ u.username as user_name,
+ u.email as user_email
+ FROM public.ml_user_bill b
+ LEFT JOIN public.ak_users u ON u.id = b.uid
+ WHERE (p_category IS NULL OR b.category = p_category)
+ AND (p_type IS NULL OR b.type = p_type)
+ AND (p_pm IS NULL OR b.pm = p_pm)
+ AND (p_start_time IS NULL OR b.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR b.created_at <= p_end_time)
+ AND (p_search IS NULL OR (
+ COALESCE(b.title, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.username, '') ILIKE '%' || p_search || '%' OR
+ COALESCE(u.email, '') ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY b.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ -- 4. 返回结果
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_user_bill_list IS '管理员资金流水列表分页查询';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除客服账号(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:account:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:account:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_accounts
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_account_delete IS '管理员删除客服账号';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取客服账号列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_accounts ka
+ JOIN public.ak_users u ON u.id = ka.user_id
+ WHERE (p_status IS NULL OR ka.status = p_status)
+ AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ ka.id,
+ ka.user_id,
+ ka.nickname,
+ ka.avatar,
+ ka.status,
+ ka.is_online,
+ ka.created_at,
+ ka.updated_at,
+ u.username as user_account
+ FROM public.ml_kefu_accounts ka
+ JOIN public.ak_users u ON u.id = ka.user_id
+ WHERE (p_status IS NULL OR ka.status = p_status)
+ AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%')
+ ORDER BY ka.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增或更新客服账号
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_save(
+ p_id UUID DEFAULT NULL,
+ p_user_id UUID DEFAULT NULL,
+ p_nickname TEXT DEFAULT NULL,
+ p_avatar TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_user_id IS NULL OR p_nickname IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields';
+ END IF;
+
+ INSERT INTO public.ml_kefu_accounts (
+ user_id, nickname, avatar, status
+ ) VALUES (
+ p_user_id, p_nickname, p_avatar, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_accounts
+ SET
+ nickname = COALESCE(p_nickname, nickname),
+ avatar = COALESCE(p_avatar, avatar),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Account not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_account_set_status
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换客服账号启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_accounts
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_account_set_status IS '管理员设置客服账号状态';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除客服自动回复配置(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:auto_reply:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:auto_reply:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_auto_replies
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_delete IS '管理员删除客服自动回复配置';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取客服自动回复配置列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_auto_replies
+ WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, keyword, content, reply_type, status,
+ created_at, updated_at
+ FROM public.ml_kefu_auto_replies
+ WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%')
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_list IS '管理员分页查询客服自动回复列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新自动回复配置
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_save(
+ p_id UUID DEFAULT NULL,
+ p_keyword TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_reply_type TEXT DEFAULT 'text',
+ p_status SMALLINT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_keyword IS NULL OR p_content IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: keyword or content';
+ END IF;
+
+ INSERT INTO public.ml_kefu_auto_replies (
+ keyword, content, reply_type, status
+ ) VALUES (
+ p_keyword, p_content, p_reply_type, p_status
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_auto_replies
+ SET
+ keyword = COALESCE(p_keyword, keyword),
+ content = COALESCE(p_content, content),
+ reply_type = COALESCE(p_reply_type, reply_type),
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Auto reply record not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_save IS '管理员新增或更新自动回复配置';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_auto_reply_set_status
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端切换客服自动回复配置启用/禁用状态
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_set_status(
+ p_id UUID,
+ p_status SMALLINT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_auto_replies
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_set_status IS '管理员设置客服自动回复状态';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_feedback_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户留言反馈列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_status SMALLINT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_kefu_feedbacks f
+ LEFT JOIN public.ak_users u ON u.id = f.user_id
+ WHERE (p_status IS NULL OR f.status = p_status)
+ AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%');
+
+ -- 3. 获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ f.id,
+ f.user_id,
+ f.nickname,
+ f.phone,
+ f.content,
+ f.status,
+ f.reply_content,
+ f.processed_at,
+ f.created_at,
+ f.updated_at,
+ u.username as user_account
+ FROM public.ml_kefu_feedbacks f
+ LEFT JOIN public.ak_users u ON u.id = f.user_id
+ WHERE (p_status IS NULL OR f.status = p_status)
+ AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%')
+ ORDER BY f.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_list IS '管理员分页查询用户留言反馈列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_feedback_process
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端处理用户留言反馈(回复内容并更新状态)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_process(
+ p_id UUID,
+ p_reply_content TEXT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 更新状态
+ UPDATE public.ml_kefu_feedbacks
+ SET
+ status = 1, -- 已处理
+ reply_content = p_reply_content,
+ processed_at = now(),
+ updated_at = now()
+ WHERE id = p_id;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_process IS '管理员处理并回复用户留言反馈';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除话术分类(支持级联软删除话术)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:word:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:word:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 级联软删除:先标记该分类下的话术为删除
+ UPDATE public.ml_kefu_words
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 4. 执行软删除分类本身:标记 deleted_at
+ UPDATE public.ml_kefu_word_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_delete IS '管理员删除话术分类';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取话术分类列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_list()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取分类列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT id, name, sort, created_at, updated_at
+ FROM public.ml_kefu_word_categories
+ ORDER BY sort ASC, created_at DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_list IS '管理员获取话术分类列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_category_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新话术分类
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_name IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields: name';
+ END IF;
+
+ INSERT INTO public.ml_kefu_word_categories (
+ name, sort
+ ) VALUES (
+ p_name, p_sort
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_word_categories
+ SET
+ name = COALESCE(p_name, name),
+ sort = COALESCE(p_sort, sort),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Category not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_save IS '管理员新增或更新话术分类';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_delete
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端删除快捷话术(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('kefu:word:delete') THEN
+ RAISE EXCEPTION 'Permission denied: kefu:word:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 执行软删除:标记 deleted_at
+ UPDATE public.ml_kefu_words
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_delete IS '管理员删除快捷话术';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_list
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端获取指定分类下的快捷话术列表
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_list(
+ p_category_id UUID DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取话术列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ w.id,
+ w.category_id,
+ w.title,
+ w.content,
+ w.sort,
+ w.created_at,
+ w.updated_at,
+ c.name as category_name
+ FROM public.ml_kefu_words w
+ JOIN public.ml_kefu_word_categories c ON c.id = w.category_id
+ WHERE (p_category_id IS NULL OR w.category_id = p_category_id)
+ AND (p_search IS NULL OR w.title ILIKE '%' || p_search || '%' OR w.content ILIKE '%' || p_search || '%')
+ ORDER BY w.sort ASC, w.created_at DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_list IS '管理员获取快捷话术列表';
+-- =====================================================================================
+-- RPC: rpc_admin_kefu_word_save
+-- 位置:docs/sql/30_rpc/kefu/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端新增或更新快捷话术
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_save(
+ p_id UUID DEFAULT NULL,
+ p_category_id UUID DEFAULT NULL,
+ p_title TEXT DEFAULT NULL,
+ p_content TEXT DEFAULT NULL,
+ p_sort INTEGER DEFAULT 0
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 新增
+ IF p_id IS NULL THEN
+ IF p_category_id IS NULL OR p_title IS NULL OR p_content IS NULL THEN
+ RAISE EXCEPTION 'Missing required fields';
+ END IF;
+
+ INSERT INTO public.ml_kefu_words (
+ category_id, title, content, sort
+ ) VALUES (
+ p_category_id, p_title, p_content, p_sort
+ ) RETURNING id INTO v_id;
+ ELSE
+ -- 3. 更新
+ UPDATE public.ml_kefu_words
+ SET
+ category_id = COALESCE(p_category_id, category_id),
+ title = COALESCE(p_title, title),
+ content = COALESCE(p_content, content),
+ sort = COALESCE(p_sort, sort),
+ updated_at = now()
+ WHERE id = p_id
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Word not found';
+ END IF;
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+
+COMMENT ON FUNCTION public.rpc_admin_kefu_word_save IS '管理员新增或更新快捷话术';
+-- RPC: rpc_admin_get_integral_stats
+-- 位置:docs/sql/30_rpc/marketing/rpc_admin_get_integral_stats_v1.sql
+-- 说明:聚合统计积分概况(总额、趋势、分布)
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_integral_stats(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_stats RECORD;
+ v_trend_data JSONB;
+ v_source_dist JSONB;
+ v_consume_dist JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算核心指标 (所有时间)
+ SELECT
+ COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE -number END), 0) as current_total,
+ COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE 0 END), 0) as cumulative_income,
+ COALESCE(SUM(CASE WHEN pm = 0 THEN number ELSE 0 END), 0) as cumulative_expend
+ INTO v_total_stats
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND status = 1;
+
+ -- 3. 趋势数据 (按日聚合)
+ SELECT jsonb_agg(t) INTO v_trend_data
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'MM-DD') AS date_group,
+ COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
+ WHERE b.category = 'integral' AND b.pm = 1 AND b.status = 1
+ AND date_trunc('day', b.created_at) = gs.day), 0) as income,
+ COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
+ WHERE b.category = 'integral' AND b.pm = 0 AND b.status = 1
+ AND date_trunc('day', b.created_at) = gs.day), 0) as expend
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ ORDER BY gs.day ASC
+ ) t;
+
+ -- 4. 来源分布 (按 type 分组)
+ SELECT jsonb_agg(t) INTO v_source_dist
+ FROM (
+ SELECT
+ type as label,
+ SUM(number) as value,
+ ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_income, 0)), 2) as percent
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND pm = 1 AND status = 1
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ -- 5. 消耗分布 (按 type 分组)
+ SELECT jsonb_agg(t) INTO v_consume_dist
+ FROM (
+ SELECT
+ type as label,
+ SUM(number) as value,
+ ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_expend, 0)), 2) as percent
+ FROM public.ml_user_bill
+ WHERE category = 'integral' AND pm = 0 AND status = 1
+ GROUP BY type
+ ORDER BY value DESC
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'totals', jsonb_build_object(
+ 'current', v_total_stats.current_total,
+ 'income', v_total_stats.cumulative_income,
+ 'expend', v_total_stats.cumulative_expend
+ ),
+ 'trend', COALESCE(v_trend_data, '[]'::jsonb),
+ 'sources', COALESCE(v_source_dist, '[]'::jsonb),
+ 'consumes', COALESCE(v_consume_dist, '[]'::jsonb)
+ );
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_integral_stats(timestamptz, timestamptz) TO authenticated;
+-- =====================================================================================
+-- Admin 订单功能 - 收银台订单列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_cashier_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search_order_no TEXT DEFAULT NULL,
+ p_search_username TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 总数:仅已支付订单
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE o.paid_at IS NOT NULL
+ AND (p_search_order_no IS NULL OR o.order_no ILIKE '%' || p_search_order_no || '%')
+ AND (p_search_username IS NULL OR u.username ILIKE '%' || p_search_username || '%');
+
+ -- 3. 明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.discount_amount,
+ o.paid_at,
+ u.username as customer_name,
+ u.phone as customer_phone
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE o.paid_at IS NOT NULL
+ AND (p_search_order_no IS NULL OR o.order_no ILIKE '%' || p_search_order_no || '%')
+ AND (p_search_username IS NULL OR u.username ILIKE '%' || p_search_username || '%')
+ ORDER BY o.paid_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 订单管理 - 主订单列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_order_status INTEGER DEFAULT NULL,
+ p_search TEXT DEFAULT NULL,
+ p_start_time TIMESTAMPTZ DEFAULT NULL,
+ p_end_time TIMESTAMPTZ DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (依赖 public.ak_users.role)
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE (p_order_status IS NULL OR o.order_status = p_order_status)
+ AND (p_start_time IS NULL OR o.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.created_at <= p_end_time)
+ AND (
+ p_search IS NULL
+ OR o.order_no ILIKE '%' || p_search || '%'
+ OR u.username ILIKE '%' || p_search || '%'
+ OR u.phone ILIKE '%' || p_search || '%'
+ );
+
+ -- 3. 获取明细列表
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.paid_amount,
+ o.discount_amount,
+ o.order_status,
+ o.payment_status,
+ o.shipping_status,
+ o.pay_type,
+ o.channel_type,
+ o.paid_at,
+ o.created_at,
+ u.username as buyer_name,
+ u.phone as buyer_phone,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url,
+ 'quantity', oi.quantity
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = o.id
+ ORDER BY oi.created_at ASC
+ LIMIT 1
+ ) as first_item_summary
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users u ON o.user_id = u.id
+ WHERE (p_order_status IS NULL OR o.order_status = p_order_status)
+ AND (p_start_time IS NULL OR o.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR o.created_at <= p_end_time)
+ AND (
+ p_search IS NULL
+ OR o.order_no ILIKE '%' || p_search || '%'
+ OR u.username ILIKE '%' || p_search || '%'
+ OR u.phone ILIKE '%' || p_search || '%'
+ )
+ ORDER BY o.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 订单来源分布 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:统计订单来源分布。
+-- 注意:当前 ml_orders DDL 未包含来源/渠道字段,本函数提供最小可用兜底:统一返回 "unknown" 汇总。
+-- 若后续新增 channel/payment_method 等字段,可在此函数中替换为按渠道分组统计。
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_source_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按渠道类型聚合统计(排除已取消)
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ CASE o.channel_type
+ WHEN 1 THEN '公众号'
+ WHEN 2 THEN '小程序'
+ WHEN 3 THEN 'H5'
+ WHEN 4 THEN 'PC'
+ WHEN 5 THEN 'APP'
+ ELSE '其他'
+ END AS source,
+ COUNT(*) AS order_count,
+ COALESCE(SUM(o.total_amount), 0) AS total_amount
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time
+ AND o.created_at <= p_end_time
+ AND o.order_status != 5
+ GROUP BY o.channel_type
+ ORDER BY order_count DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 核心 KPI 汇总 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:获取指定时间段内的订单量、销售额、退款数及退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_order_count BIGINT;
+ v_total_amount DECIMAL(12,2);
+ v_refund_count BIGINT;
+ v_refund_amount DECIMAL(12,2);
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计订单汇总(不含已取消)
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(total_amount), 0)
+ INTO v_order_count, v_total_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND order_status != 5; -- 5: 已取消
+
+ -- 3. 统计退款汇总
+ -- 注意:这里基于 ml_orders 的 payment_status 或 order_status 判断已退款
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(discount_amount), 0) -- 暂时用这个,若有真实退款金额字段请替换
+ INTO v_refund_count, v_refund_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time
+ AND created_at <= p_end_time
+ AND order_status IN (6, 7); -- 6: 退款中, 7: 已退款
+
+ RETURN jsonb_build_object(
+ 'order_count', v_order_count,
+ 'total_amount', v_total_amount,
+ 'refund_count', v_refund_count,
+ 'refund_amount', v_refund_amount
+ );
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 趋势统计 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按天聚合指定时间范围内的订单量/销售额/退款量/退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_trend(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ,
+ p_group_by TEXT DEFAULT 'day'
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 目前仅支持 day
+ IF p_group_by IS NULL OR p_group_by != 'day' THEN
+ RAISE EXCEPTION 'Unsupported group_by';
+ END IF;
+
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', o.created_at), 'YYYY-MM-DD') AS date_group,
+ COUNT(*) FILTER (WHERE o.order_status != 5) AS order_count,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.order_status != 5), 0) AS total_amount,
+ COUNT(*) FILTER (WHERE o.order_status IN (6, 7)) AS refund_count,
+ COALESCE(SUM(o.discount_amount) FILTER (WHERE o.order_status IN (6, 7)), 0) AS refund_amount
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time
+ AND o.created_at <= p_end_time
+ GROUP BY date_trunc('day', o.created_at)
+ ORDER BY date_trunc('day', o.created_at) ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单统计 - 订单类型分布统计 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按订单类型(普通、收银、核销)统计指定时间段内的销售额及其占比
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_order_type_stats(
+ p_start_time TIMESTAMPTZ,
+ p_end_time TIMESTAMPTZ
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_amount DECIMAL(12,2);
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 计算总销售额(用于算占比)
+ SELECT COALESCE(SUM(total_amount), 0) INTO v_total_amount
+ FROM public.ml_orders
+ WHERE created_at >= p_start_time AND created_at <= p_end_time
+ AND order_status != 5; -- 排除已取消
+
+ -- 3. 按类型统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ CASE o.order_type
+ WHEN 1 THEN '普通订单'
+ WHEN 2 THEN '收银订单'
+ WHEN 3 THEN '核销订单'
+ ELSE '其他类型'
+ END AS name,
+ COALESCE(SUM(o.total_amount), 0) AS amount,
+ CASE
+ WHEN v_total_amount > 0 THEN ROUND((COALESCE(SUM(o.total_amount), 0) / v_total_amount * 100), 2)
+ ELSE 0
+ END AS rate
+ FROM public.ml_orders o
+ WHERE o.created_at >= p_start_time AND o.created_at <= p_end_time
+ AND o.order_status != 5
+ GROUP BY o.order_type
+ ORDER BY amount DESC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 订单功能 - 售后退款列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_refund_order_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_refund_status INTEGER DEFAULT NULL,
+ p_search TEXT DEFAULT NULL
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_refund_orders ro
+ WHERE (p_refund_status IS NULL OR ro.refund_status = p_refund_status)
+ AND (p_search IS NULL OR (
+ ro.refund_no ILIKE '%' || p_search || '%' OR
+ EXISTS (
+ SELECT 1 FROM public.ml_orders o
+ WHERE o.id = ro.order_id AND o.order_no ILIKE '%' || p_search || '%'
+ )
+ ));
+
+ -- 3. 获取明细数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ ro.id,
+ ro.refund_no,
+ ro.refund_amount,
+ ro.refund_status,
+ ro.refund_reason,
+ ro.applied_at,
+ o.order_no,
+ o.order_status,
+ u.username as customer_name,
+ u.phone as customer_phone,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = ro.order_id
+ LIMIT 1
+ ) as product_summary
+ FROM public.ml_refund_orders ro
+ LEFT JOIN public.ml_orders o ON ro.order_id = o.id
+ LEFT JOIN public.ak_users u ON ro.user_id = u.id
+ WHERE (p_refund_status IS NULL OR ro.refund_status = p_refund_status)
+ AND (p_search IS NULL OR (
+ ro.refund_no ILIKE '%' || p_search || '%' OR
+ o.order_no ILIKE '%' || p_search || '%'
+ ))
+ ORDER BY ro.applied_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 订单功能 - 核销记录列表分页查询 RPC
+-- 位置:docs/sql/30_rpc/order/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_write_off_record_list(
+ p_page INTEGER DEFAULT 1,
+ p_page_size INTEGER DEFAULT 15,
+ p_search TEXT DEFAULT NULL,
+ p_verified_only BOOLEAN DEFAULT TRUE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_offset INTEGER;
+ v_total BIGINT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ v_offset := (p_page - 1) * p_page_size;
+
+ -- 2. 获取总数(核销订单类型 = 3)
+ SELECT COUNT(*) INTO v_total
+ FROM public.ml_orders o
+ WHERE o.order_type = 3
+ AND (p_verified_only = FALSE OR o.verified_at IS NOT NULL)
+ AND (p_search IS NULL OR o.order_no ILIKE '%' || p_search || '%');
+
+ -- 3. 获取明细
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ o.id,
+ o.order_no,
+ o.total_amount,
+ o.payment_status,
+ o.order_status,
+ o.created_at,
+ o.verified_at,
+ buyer.username as customer_name,
+ buyer.phone as customer_phone,
+ verifier.username as verifier_name,
+ (
+ SELECT jsonb_build_object(
+ 'product_name', oi.product_name,
+ 'image_url', oi.image_url
+ )
+ FROM public.ml_order_items oi
+ WHERE oi.order_id = o.id
+ LIMIT 1
+ ) as product_summary
+ FROM public.ml_orders o
+ LEFT JOIN public.ak_users buyer ON o.user_id = buyer.id
+ LEFT JOIN public.ak_users verifier ON o.verifier_id = verifier.id
+ WHERE o.order_type = 3
+ AND (p_verified_only = FALSE OR o.verified_at IS NOT NULL)
+ AND (p_search IS NULL OR o.order_no ILIKE '%' || p_search || '%')
+ ORDER BY o.verified_at DESC NULLS LAST, o.created_at DESC
+ LIMIT p_page_size
+ OFFSET v_offset
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;-- =====================================================================================
+-- Admin 商品模块 - 删除分类 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1(支持级联软删除商品关联)
+-- 依赖:ml_categories, ml_products, ak_users 表已存在
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_category_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('product:category:delete') THEN
+ RAISE EXCEPTION 'Permission denied: product:category:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 检查是否有子分类 (方案 1)
+ IF EXISTS (
+ SELECT 1 FROM public.ml_categories
+ WHERE parent_id = p_id AND deleted_at IS NULL
+ ) THEN
+ RAISE EXCEPTION '请先删除该分类下的子分类';
+ END IF;
+
+ -- 4. 级联软删除:该分类下的所有商品
+ UPDATE public.ml_products
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE category_id = p_id AND deleted_at IS NULL;
+
+ -- 5. 执行软删除分类本身
+ UPDATE public.ml_categories
+ SET deleted_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ RETURN FOUND;
+END;
+$$;
+-- RPC: rpc_admin_get_product_reviews
+-- 作用:管理端分页获取商品评论列表,包含商品名称、用户名及规格
+-- 位置:docs/sql/30_rpc/product/rpc_admin_get_product_reviews_v1.sql
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_get_product_reviews(
+ p_search_product text DEFAULT NULL,
+ p_search_user text DEFAULT NULL,
+ p_status integer DEFAULT NULL,
+ p_start_time timestamptz DEFAULT NULL,
+ p_end_time timestamptz DEFAULT NULL,
+ p_page integer DEFAULT 1,
+ p_page_size integer DEFAULT 20
+)
+RETURNS TABLE (
+ id uuid,
+ product_id uuid,
+ product_name text,
+ product_image text,
+ user_id uuid,
+ username text,
+ rating integer,
+ content text,
+ merchant_reply text,
+ status integer,
+ created_at timestamptz,
+ total_count bigint
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_offset integer := (p_page - 1) * p_page_size;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE ak_users.id = auth.uid() AND ak_users.role = 'admin'
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ RETURN QUERY
+ WITH filtered_reviews AS (
+ SELECT
+ r.*,
+ p.name as p_name,
+ p.main_image_url as p_image,
+ u.username as u_name,
+ COUNT(*) OVER() as full_count
+ FROM public.ml_product_reviews r
+ LEFT JOIN public.ml_products p ON r.product_id = p.id
+ LEFT JOIN public.ak_users u ON r.user_id = u.id
+ WHERE (p_search_product IS NULL OR p.name ILIKE '%' || p_search_product || '%')
+ AND (p_search_user IS NULL OR u.username ILIKE '%' || p_search_user || '%')
+ AND (p_status IS NULL OR r.status = p_status)
+ AND (p_start_time IS NULL OR r.created_at >= p_start_time)
+ AND (p_end_time IS NULL OR r.created_at <= p_end_time)
+ )
+ SELECT
+ fr.id,
+ fr.product_id,
+ fr.p_name as product_name,
+ fr.p_image as product_image,
+ fr.user_id,
+ fr.u_name as username,
+ fr.rating,
+ fr.content,
+ fr.merchant_reply,
+ fr.status,
+ fr.created_at,
+ fr.full_count as total_count
+ FROM filtered_reviews fr
+ ORDER BY fr.created_at DESC
+ LIMIT p_page_size OFFSET v_offset;
+END;
+$$;
+
+-- 授权
+REVOKE ALL ON FUNCTION public.rpc_admin_get_product_reviews(text, text, integer, timestamptz, timestamptz, integer, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.rpc_admin_get_product_reviews(text, text, integer, timestamptz, timestamptz, integer, integer) TO authenticated;
+-- =====================================================================================
+-- Admin 商品模块 - 商品统计概况 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 依赖:ml_products, ml_orders, ml_browse_history, ak_users
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_stats(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_stats JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计核心指标
+ -- 商品浏览量 (PV), 访客数 (UV), 支付件数, 支付金额, 退款件数, 退款金额
+ WITH stats AS (
+ SELECT
+ (SELECT COALESCE(SUM(browse_duration), 0) FROM public.ml_browse_history WHERE created_at BETWEEN p_start_time AND p_end_time) as total_views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history WHERE created_at BETWEEN p_start_time AND p_end_time) as total_visitors,
+ (SELECT COALESCE(SUM(quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_count,
+ (SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders
+ WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COUNT(*) FROM public.ml_orders WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status = 7) as refund_count,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders WHERE created_at BETWEEN p_start_time AND p_end_time AND order_status = 7) as refund_amount
+ )
+ SELECT jsonb_build_object(
+ 'views', total_views,
+ 'visitors', total_visitors,
+ 'pay_count', pay_count,
+ 'pay_amount', pay_amount,
+ 'refund_count', refund_count,
+ 'refund_amount', refund_amount
+ ) INTO v_stats FROM stats;
+
+ RETURN v_stats;
+END;
+$$;
+
+-- =====================================================================================
+-- Admin 商品模块 - 商品排行 RPC
+-- =====================================================================================
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_ranking(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE,
+ p_sort_by TEXT DEFAULT 'sales', -- views, sales, amount
+ p_limit INTEGER DEFAULT 10
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取排行数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ p.id,
+ p.name,
+ p.main_image_url as image,
+ COALESCE(p.view_count, 0) as views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history bh WHERE bh.product_id = p.id AND bh.created_at BETWEEN p_start_time AND p_end_time) as visitors,
+ (SELECT COALESCE(SUM(quantity), 0) FROM public.ml_shopping_cart sc WHERE sc.product_id = p.id AND sc.created_at BETWEEN p_start_time AND p_end_time) as cart_count,
+ (SELECT COUNT(DISTINCT o.id) FROM public.ml_orders o JOIN public.ml_order_items oi ON o.id = oi.order_id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time) as order_count,
+ (SELECT COALESCE(SUM(oi.quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_count,
+ (SELECT COALESCE(SUM(oi.total_amount), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COUNT(*) FROM public.ml_user_favorites f WHERE f.target_id = p.id AND f.target_type = 1 AND f.created_at BETWEEN p_start_time AND p_end_time) as fav_count
+ FROM public.ml_products p
+ WHERE p.status != 4
+ ORDER BY
+ CASE
+ WHEN p_sort_by = 'views' THEN COALESCE(p.view_count, 0)
+ WHEN p_sort_by = 'sales' THEN (
+ SELECT COALESCE(SUM(oi.quantity), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)
+ )
+ WHEN p_sort_by = 'amount' THEN (
+ SELECT COALESCE(SUM(oi.total_amount), 0) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id
+ WHERE oi.product_id = p.id AND o.created_at BETWEEN p_start_time AND p_end_time AND o.order_status NOT IN (1, 5)
+ )
+ ELSE COALESCE(p.view_count, 0)
+ END DESC
+ LIMIT p_limit
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- Admin 商品管理 - 商品状态汇总统计 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:统计出售中、仓库中、草稿箱、回收站各状态的商品数量
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_count_stats()
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_result JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 统计各状态数量
+ -- status 定义:1:上架(出售中), 2:下架(仓库中), 3:草稿, 4:逻辑删除(回收站)
+ SELECT jsonb_build_object(
+ 'selling', COUNT(*) FILTER (WHERE status = 1),
+ 'warehouse', COUNT(*) FILTER (WHERE status = 2),
+ 'draft', COUNT(*) FILTER (WHERE status = 3),
+ 'recycle', COUNT(*) FILTER (WHERE status = 4)
+ ) INTO v_result
+ FROM public.ml_products;
+
+ RETURN v_result;
+END;
+$$;
+-- =====================================================================================
+-- Admin 商品统计 - 营业趋势统计 RPC
+-- 位置:docs/sql/30_rpc/product/
+-- 对象类型:RPC 函数(SECURITY DEFINER)
+-- 版本:v1
+-- 说明:按天聚合指定时间范围内的商品浏览量、访客量、支付金额及退款金额
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_product_trend(
+ p_start_time TIMESTAMP WITH TIME ZONE,
+ p_end_time TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 按日聚合统计
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ to_char(date_trunc('day', gs.day), 'YYYY-MM-DD') AS date_group,
+ (SELECT COUNT(*) FROM public.ml_browse_history bh WHERE date_trunc('day', bh.created_at) = gs.day) as views,
+ (SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history bh WHERE date_trunc('day', bh.created_at) = gs.day) as visitors,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders o WHERE date_trunc('day', o.created_at) = gs.day AND o.order_status NOT IN (1, 5)) as pay_amount,
+ (SELECT COALESCE(SUM(total_amount), 0) FROM public.ml_orders o WHERE date_trunc('day', o.created_at) = gs.day AND o.order_status = 7) as refund_amount
+ FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
+ ORDER BY gs.day ASC
+ ) t;
+
+ RETURN COALESCE(v_items, '[]'::jsonb);
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户分组(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:group:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:group:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_groups
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户分组列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_groups
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, remark, status,
+ created_at, updated_at, deleted_at
+ FROM public.ak_user_groups
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户分组(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_remark TEXT DEFAULT NULL,
+ p_status INT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_groups(
+ name, remark, status,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_remark, COALESCE(p_status, 1),
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_groups
+ SET
+ name = p_name,
+ remark = p_remark,
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_group_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户分组状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_group_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_groups
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户标签(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:label:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:label:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_labels
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户标签列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_labels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, color, remark, status,
+ created_at, updated_at, deleted_at
+ FROM public.ak_user_labels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%' OR remark ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ ORDER BY created_at DESC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户标签(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_color TEXT DEFAULT NULL,
+ p_remark TEXT DEFAULT NULL,
+ p_status INT DEFAULT 1
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_labels(
+ name, color, remark, status,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_color, p_remark, COALESCE(p_status, 1),
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_labels
+ SET
+ name = p_name,
+ color = p_color,
+ remark = p_remark,
+ status = COALESCE(p_status, status),
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_label_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户标签状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_label_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_labels
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_delete
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:逻辑删除用户等级(使用通用权限校验)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_delete(
+ p_id UUID
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+ v_user_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT public.check_admin_permission('user:level:delete') THEN
+ RAISE EXCEPTION 'Permission denied: user:level:delete';
+ END IF;
+
+ -- 2. 获取当前操作用户 ID
+ SELECT id INTO v_user_id FROM public.ak_users WHERE auth_id = auth.uid();
+
+ -- 3. 逻辑删除
+ UPDATE public.ak_user_levels
+ SET deleted_at = now(),
+ updated_at = now(),
+ deleted_by = v_user_id
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_list
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:管理端分页获取用户等级列表,支持搜索、状态筛选及逻辑删除过滤
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_list(
+ p_page INT,
+ p_page_size INT,
+ p_search TEXT DEFAULT NULL,
+ p_status INT DEFAULT NULL,
+ p_is_visible BOOLEAN DEFAULT NULL,
+ p_include_deleted BOOLEAN DEFAULT FALSE
+)
+RETURNS JSONB
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total INT;
+ v_items JSONB;
+BEGIN
+ -- 1. 权限检查 (依赖 public.get_current_user_role())
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 获取总数
+ SELECT COUNT(*) INTO v_total
+ FROM public.ak_user_levels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ AND (p_is_visible IS NULL OR is_visible = p_is_visible);
+
+ -- 3. 分页获取数据
+ SELECT jsonb_agg(t) INTO v_items
+ FROM (
+ SELECT
+ id, name, level_weight, min_experience, discount_percent,
+ is_visible, status, icon_url, bg_image_url, bg_style_json,
+ remark, created_at, updated_at, deleted_at
+ FROM public.ak_user_levels
+ WHERE (p_include_deleted OR deleted_at IS NULL)
+ AND (p_search IS NULL OR name ILIKE '%' || p_search || '%')
+ AND (p_status IS NULL OR status = p_status)
+ AND (p_is_visible IS NULL OR is_visible = p_is_visible)
+ ORDER BY level_weight ASC
+ LIMIT p_page_size
+ OFFSET (p_page - 1) * p_page_size
+ ) t;
+
+ RETURN jsonb_build_object(
+ 'total', v_total,
+ 'items', COALESCE(v_items, '[]'::jsonb)
+ );
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_save
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:新增/更新用户等级(逻辑删除记录默认不允许更新)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_save(
+ p_id UUID DEFAULT NULL,
+ p_name TEXT,
+ p_level_weight INT,
+ p_min_experience INT,
+ p_discount_percent INT,
+ p_is_visible BOOLEAN,
+ p_status INT,
+ p_icon_url TEXT DEFAULT NULL,
+ p_bg_image_url TEXT DEFAULT NULL,
+ p_bg_style_json JSONB DEFAULT NULL,
+ p_remark TEXT DEFAULT NULL
+)
+RETURNS UUID
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_id UUID;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ -- 2. 参数校验(最小化)
+ IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
+ RAISE EXCEPTION 'Invalid name';
+ END IF;
+
+ IF p_level_weight < 0 OR p_min_experience < 0 THEN
+ RAISE EXCEPTION 'Invalid level_weight or min_experience';
+ END IF;
+
+ IF p_discount_percent < 1 OR p_discount_percent > 100 THEN
+ RAISE EXCEPTION 'Invalid discount_percent';
+ END IF;
+
+ -- 3. 新增
+ IF p_id IS NULL THEN
+ INSERT INTO public.ak_user_levels(
+ name, level_weight, min_experience, discount_percent,
+ is_visible, status,
+ icon_url, bg_image_url, bg_style_json,
+ remark,
+ created_at, updated_at, deleted_at
+ ) VALUES (
+ p_name, p_level_weight, p_min_experience, p_discount_percent,
+ p_is_visible, p_status,
+ p_icon_url, p_bg_image_url, p_bg_style_json,
+ p_remark,
+ now(), now(), NULL
+ )
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+ END IF;
+
+ -- 4. 更新(不允许更新已删除记录)
+ UPDATE public.ak_user_levels
+ SET
+ name = p_name,
+ level_weight = p_level_weight,
+ min_experience = p_min_experience,
+ discount_percent = p_discount_percent,
+ is_visible = p_is_visible,
+ status = p_status,
+ icon_url = p_icon_url,
+ bg_image_url = p_bg_image_url,
+ bg_style_json = p_bg_style_json,
+ remark = p_remark,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL
+ RETURNING id INTO v_id;
+
+ IF v_id IS NULL THEN
+ RAISE EXCEPTION 'Not found or deleted';
+ END IF;
+
+ RETURN v_id;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_set_status
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户等级状态(启用/禁用)
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_set_status(
+ p_id UUID,
+ p_status INT
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_levels
+ SET status = p_status,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+-- =====================================================================================
+-- RPC: rpc_admin_user_level_set_visible
+-- 位置:docs/sql/30_rpc/user/
+-- 对象类型:RPC 函数 (SECURITY DEFINER)
+-- 版本:v1
+-- 说明:设置用户等级是否展示
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_admin_user_level_set_visible(
+ p_id UUID,
+ p_is_visible BOOLEAN
+)
+RETURNS BOOLEAN
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_ok BOOLEAN;
+BEGIN
+ -- 1. 权限检查
+ IF NOT EXISTS (
+ SELECT 1 FROM public.ak_users
+ WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
+ ) THEN
+ RAISE EXCEPTION 'Permission denied';
+ END IF;
+
+ UPDATE public.ak_user_levels
+ SET is_visible = p_is_visible,
+ updated_at = now()
+ WHERE id = p_id AND deleted_at IS NULL;
+
+ GET DIAGNOSTICS v_ok = ROW_COUNT;
+ RETURN v_ok;
+END;
+$$;
+
+```
+
+## 关键 RPC 清单
+
+| 函数名 | 参数 | 返回 | 用途 |
+|--------|------|------|------|
+| rpc_admin_get_overall_stats | | JSONB | |
+| rpc_admin_get_system_info | | JSONB | |
+| rpc_admin_system_config_get | p_key TEXT | JSONB | |
+| rpc_admin_system_config_save | p_key TEXT, p_value JSONB, p_description TEXT DEFAULT NULL | BOOLEAN | |
+| rpc_analytics_user_gender_distribution | p_start_date DATE, p_end_date DATE | TABLE ( name TEXT, value BIGINT ) | |
+| check_admin_permission | p_permission_code TEXT DEFAULT NULL | BOOLEAN | |
+| get_current_user_role | | TEXT | |
+| handle_new_user | | trigger | |
+| handle_new_user | | trigger | |
+| handle_new_user | | trigger | |
+| rpc_admin_delete_permission | p_id UUID | BOOLEAN | |
+| rpc_admin_delete_role | p_id UUID | BOOLEAN | |
+| rpc_admin_get_admin_list | p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAU | JSONB | |
+| rpc_admin_get_permission_list | | JSONB | |
+| rpc_admin_get_role_list | p_search TEXT DEFAULT NULL, p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAUL | JSONB | |
+| rpc_admin_save_permission | p_id UUID DEFAULT NULL, p_parent_id UUID DEFAULT NULL, p_name TEXT DEFAULT NULL, | UUID | |
+| rpc_admin_save_role | p_id UUID DEFAULT NULL, p_name TEXT DEFAULT NULL, p_code TEXT DEFAULT NULL, p_de | UUID | |
+| rpc_admin_article_category_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_article_category_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search TEXT DEFAULT | JSONB | |
+| rpc_admin_article_category_save | p_id UUID DEFAULT NULL, p_name TEXT DEFAULT NULL, p_icon TEXT DEFAULT NULL, p_so | UUID | |
+| rpc_admin_article_category_set_status | p_id UUID, p_status SMALLINT | BOOLEAN | |
+| rpc_admin_article_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_article_get_detail | p_id UUID | JSONB | |
+| rpc_admin_article_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_category_id UUID DEF | JSONB | |
+| rpc_admin_article_save | p_id UUID DEFAULT NULL, p_category_id UUID DEFAULT NULL, p_title TEXT DEFAULT NU | UUID | |
+| rpc_admin_article_set_status | p_id UUID, p_status SMALLINT | BOOLEAN | |
+| rpc_admin_delete_diy_page | p_id uuid | boolean | |
+| rpc_admin_get_diy_page_list | p_search text DEFAULT NULL, p_type text DEFAULT NULL, p_page integer DEFAULT 1, | JSONB | |
+| rpc_admin_save_diy_page | p_id uuid DEFAULT NULL, p_name text DEFAULT NULL, p_type text DEFAULT NULL, p_co | uuid | |
+| rpc_admin_set_home_page | p_id uuid | boolean | |
+| rpc_admin_delete_delivery_staff | p_id UUID | BOOLEAN | |
+| rpc_admin_delete_delivery_station | p_id UUID | BOOLEAN | |
+| rpc_admin_get_delivery_staff_list | p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAU | JSONB | |
+| rpc_admin_get_delivery_staff_list | p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAU | JSONB | |
+| rpc_admin_get_delivery_station_list | p_search TEXT DEFAULT NULL, p_status SMALLINT DEFAULT NULL, p_page INTEGER DEFAU | JSONB | |
+| rpc_admin_save_delivery_staff | p_id UUID DEFAULT NULL, p_nickname TEXT DEFAULT NULL, p_avatar TEXT DEFAULT NULL | UUID | |
+| rpc_admin_save_delivery_staff | p_id UUID DEFAULT NULL, p_uid UUID DEFAULT NULL, p_station_id UUID DEFAULT NULL, | UUID | |
+| rpc_admin_save_delivery_station | p_id UUID DEFAULT NULL, p_name TEXT DEFAULT NULL, p_phone TEXT DEFAULT NULL, p_a | UUID | |
+| rpc_homecare_dispatch_candidate | p_service_code TEXT DEFAULT NULL, p_station_id UUID DEFAULT NULL | JSONB | |
+| rpc_admin_delete_agent | p_uid uuid | boolean | |
+| rpc_admin_delete_division | p_uid uuid | boolean | |
+| rpc_admin_get_agent_apply_list | p_status text DEFAULT 'all', p_search text DEFAULT NULL, p_page integer DEFAULT | TABLE ( id uuid, uid uuid, name text, phone text, dept_uid uuid, dept_name text, | |
+| rpc_admin_get_agent_list | p_search text DEFAULT NULL, p_page integer DEFAULT 1, p_page_size integer DEFAUL | TABLE ( uid uuid, name text, division_uid uuid, division_name text, commission_r | |
+| rpc_admin_get_division_list | p_search text DEFAULT NULL, p_page integer DEFAULT 1, p_page_size integer DEFAUL | TABLE ( uid uuid, name text, invite_code text, commission_ratio numeric, is_enab | |
+| rpc_admin_get_promoter_list | p_search text DEFAULT NULL, p_page integer DEFAULT 1, p_page_size integer DEFAUL | TABLE ( id uuid, nickname text, name text, phone text, avatar_url text, level te | |
+| rpc_admin_process_agent_apply | p_id uuid, p_status text, -- approved / rejected p_refusal_reason text DEFAULT N | boolean | |
+| rpc_admin_save_agent | p_uid uuid, p_division_uid uuid, p_name text, p_commission_ratio numeric, p_is_e | uuid | |
+| rpc_admin_save_division | p_uid uuid, p_name text, p_invite_code text, p_commission_ratio numeric, p_is_en | uuid | |
+| rpc_admin_balance_distribution | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_balance_stats | | JSONB | |
+| rpc_admin_balance_trend | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_extract_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_status SMALLINT DEFA | JSONB | |
+| rpc_admin_extract_review | p_extract_id UUID, p_status SMALLINT, -- 1: 通过, -1: 驳回 p_refusal_reason TEXT DEF | VOID | |
+| rpc_admin_finance_bill_summary | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE, p_in | JSONB | |
+| rpc_admin_finance_overview | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_invoice_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_status SMALLINT DEFA | JSONB | |
+| rpc_admin_invoice_process | p_id UUID, p_status SMALLINT, -- 1: 已开票, -1: 已拒绝 p_invoice_url TEXT DEFAULT NULL | BOOLEAN | |
+| rpc_admin_recharge_audit | p_recharge_id UUID, p_mark TEXT DEFAULT '管理员人工审计/补单' | VOID | |
+| rpc_admin_recharge_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_paid SMALLINT DEFAUL | JSONB | |
+| rpc_admin_user_bill_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_category VARCHAR DEF | JSONB | |
+| rpc_admin_kefu_account_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_kefu_account_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search TEXT DEFAULT | JSONB | |
+| rpc_admin_kefu_account_save | p_id UUID DEFAULT NULL, p_user_id UUID DEFAULT NULL, p_nickname TEXT DEFAULT NUL | UUID | |
+| rpc_admin_kefu_account_set_status | p_id UUID, p_status SMALLINT | BOOLEAN | |
+| rpc_admin_kefu_auto_reply_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_kefu_auto_reply_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search TEXT DEFAULT | JSONB | |
+| rpc_admin_kefu_auto_reply_save | p_id UUID DEFAULT NULL, p_keyword TEXT DEFAULT NULL, p_content TEXT DEFAULT NULL | UUID | |
+| rpc_admin_kefu_auto_reply_set_status | p_id UUID, p_status SMALLINT | BOOLEAN | |
+| rpc_admin_kefu_feedback_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search TEXT DEFAULT | JSONB | |
+| rpc_admin_kefu_feedback_process | p_id UUID, p_reply_content TEXT | BOOLEAN | |
+| rpc_admin_kefu_word_category_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_kefu_word_category_list | | JSONB | |
+| rpc_admin_kefu_word_category_save | p_id UUID DEFAULT NULL, p_name TEXT DEFAULT NULL, p_sort INTEGER DEFAULT 0 | UUID | |
+| rpc_admin_kefu_word_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_kefu_word_list | p_category_id UUID DEFAULT NULL, p_search TEXT DEFAULT NULL | JSONB | |
+| rpc_admin_kefu_word_save | p_id UUID DEFAULT NULL, p_category_id UUID DEFAULT NULL, p_title TEXT DEFAULT NU | UUID | |
+| rpc_admin_get_integral_stats | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_cashier_order_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search_order_no TEXT | JSONB | |
+| rpc_admin_order_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_order_status INTEGER | JSONB | |
+| rpc_admin_order_source_stats | p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ | JSONB | |
+| rpc_admin_order_stats | p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ | JSONB | |
+| rpc_admin_order_trend | p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ, p_group_by TEXT DEFAULT 'day' | JSONB | |
+| rpc_admin_order_type_stats | p_start_time TIMESTAMPTZ, p_end_time TIMESTAMPTZ | JSONB | |
+| rpc_admin_refund_order_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_refund_status INTEGE | JSONB | |
+| rpc_admin_write_off_record_list | p_page INTEGER DEFAULT 1, p_page_size INTEGER DEFAULT 15, p_search TEXT DEFAULT | JSONB | |
+| rpc_admin_category_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_get_product_reviews | p_search_product text DEFAULT NULL, p_search_user text DEFAULT NULL, p_status in | TABLE ( id uuid, product_id uuid, product_name text, product_image text, user_id | |
+| rpc_admin_product_stats | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_product_ranking | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE, p_so | JSONB | |
+| rpc_admin_product_count_stats | | JSONB | |
+| rpc_admin_product_trend | p_start_time TIMESTAMP WITH TIME ZONE, p_end_time TIMESTAMP WITH TIME ZONE | JSONB | |
+| rpc_admin_user_group_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_user_group_list | p_page INT, p_page_size INT, p_search TEXT DEFAULT NULL, p_status INT DEFAULT NU | JSONB | |
+| rpc_admin_user_group_save | p_id UUID DEFAULT NULL, p_name TEXT, p_remark TEXT DEFAULT NULL, p_status INT DE | UUID | |
+| rpc_admin_user_group_set_status | p_id UUID, p_status INT | BOOLEAN | |
+| rpc_admin_user_label_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_user_label_list | p_page INT, p_page_size INT, p_search TEXT DEFAULT NULL, p_status INT DEFAULT NU | JSONB | |
+| rpc_admin_user_label_save | p_id UUID DEFAULT NULL, p_name TEXT, p_color TEXT DEFAULT NULL, p_remark TEXT DE | UUID | |
+| rpc_admin_user_label_set_status | p_id UUID, p_status INT | BOOLEAN | |
+| rpc_admin_user_level_delete | p_id UUID | BOOLEAN | |
+| rpc_admin_user_level_list | p_page INT, p_page_size INT, p_search TEXT DEFAULT NULL, p_status INT DEFAULT NU | JSONB | |
+| rpc_admin_user_level_save | p_id UUID DEFAULT NULL, p_name TEXT, p_level_weight INT, p_min_experience INT, p | UUID | |
+| rpc_admin_user_level_set_status | p_id UUID, p_status INT | BOOLEAN | |
+| rpc_admin_user_level_set_visible | p_id UUID, p_is_visible BOOLEAN | BOOLEAN | |
diff --git a/mall_sql/migrations/20260601_fix_hss_assignment_rls.sql b/mall_sql/migrations/20260601_fix_hss_assignment_rls.sql
new file mode 100644
index 00000000..1c07f50b
--- /dev/null
+++ b/mall_sql/migrations/20260601_fix_hss_assignment_rls.sql
@@ -0,0 +1,29 @@
+BEGIN;
+
+-- =====================================================================================
+-- Fix: hss_service_assignments INSERT RLS
+-- Problem: consumers cannot insert assignments when auto-dispatching on order creation
+-- because hss_service_assignments_staff_insert only allows the staff themselves.
+-- Solution: allow the order owner (consumer) to insert assignments for their own orders.
+-- =====================================================================================
+
+DROP POLICY IF EXISTS hss_service_assignments_order_owner_insert ON public.hss_service_assignments;
+
+CREATE POLICY hss_service_assignments_order_owner_insert
+ ON public.hss_service_assignments
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1
+ FROM public.hss_service_orders o
+ WHERE o.id = order_id
+ AND o.user_id IN (SELECT id FROM public.ak_users WHERE auth_id = auth.uid())
+ AND o.deleted_at IS NULL
+ )
+ );
+
+COMMENT ON POLICY hss_service_assignments_order_owner_insert
+ ON public.hss_service_assignments IS '允许订单所有者(消费者)在下单时为其订单写入自动派单记录';
+
+COMMIT;
diff --git a/mall_sql/migrations/20260601_hss_auto_dispatch_rpc_v2.sql b/mall_sql/migrations/20260601_hss_auto_dispatch_rpc_v2.sql
new file mode 100644
index 00000000..535832bd
--- /dev/null
+++ b/mall_sql/migrations/20260601_hss_auto_dispatch_rpc_v2.sql
@@ -0,0 +1,1062 @@
+BEGIN;
+
+-- =====================================================================================
+-- RPC: rpc_homecare_auto_dispatch
+-- Version: v2_secure_dispatch
+-- Purpose:
+-- 1. 消费者支付成功后,由数据库自动派单
+-- 2. 禁止前端直接 INSERT hss_service_assignments
+-- 3. 支持派单失败原因返回、失败记录、重新派单
+-- 4. 增加权限校验、支付校验、并发锁、重复派单保护
+--
+-- Dispatch rules:
+-- - 订单必须属于当前登录用户
+-- - 订单必须已经支付 payment_status = 2
+-- - 订单状态必须仍为 created
+-- - 服务人员必须在线、启用、未删除
+-- - 可选匹配:站点、资质、服务半径、预约时间冲突
+--
+-- Return fields:
+-- success BOOLEAN
+-- code TEXT 前端用于分支判断
+-- message TEXT 前端弹窗 content 直接使用
+-- display_type TEXT modal / toast
+-- retryable BOOLEAN 是否允许显示“重新派单”
+-- dispatch_status TEXT
+-- =====================================================================================
+
+
+-- =====================================================================================
+-- 1. 收紧前端直接写入派单表的权限
+-- =====================================================================================
+
+DROP POLICY IF EXISTS hss_service_assignments_order_owner_insert
+ON public.hss_service_assignments;
+
+
+-- =====================================================================================
+-- 2. 补充服务订单派单字段
+-- =====================================================================================
+
+ALTER TABLE public.hss_service_orders
+ ADD COLUMN IF NOT EXISTS payment_status SMALLINT NOT NULL DEFAULT 1,
+ ADD COLUMN IF NOT EXISTS dispatch_status TEXT NOT NULL DEFAULT 'pending',
+ ADD COLUMN IF NOT EXISTS dispatch_error_code TEXT,
+ ADD COLUMN IF NOT EXISTS dispatch_error_message TEXT,
+ ADD COLUMN IF NOT EXISTS dispatch_failed_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS dispatch_attempt_count INTEGER NOT NULL DEFAULT 0,
+
+ -- 订单需要的服务资质编码,例如 BASIC_CARE、PROFESSIONAL_NURSING
+ ADD COLUMN IF NOT EXISTS required_qualification_code TEXT,
+
+ -- 指定服务站点,为空时不限定站点
+ ADD COLUMN IF NOT EXISTS dispatch_station_id TEXT,
+
+ -- 用户服务地址经纬度;用于距离筛选,不负责地址文字转换
+ ADD COLUMN IF NOT EXISTS service_lat NUMERIC(10,7),
+ ADD COLUMN IF NOT EXISTS service_lng NUMERIC(10,7),
+
+ -- 用户预约服务时间;为空时表示立即上门或未指定时间
+ ADD COLUMN IF NOT EXISTS scheduled_start_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS scheduled_end_at TIMESTAMPTZ;
+
+UPDATE public.hss_service_orders
+SET dispatch_status = 'pending'
+WHERE dispatch_status IS NULL;
+
+UPDATE public.hss_service_orders
+SET dispatch_attempt_count = 0
+WHERE dispatch_attempt_count IS NULL;
+
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'chk_hss_service_orders_dispatch_status'
+ AND conrelid = 'public.hss_service_orders'::regclass
+ ) THEN
+ ALTER TABLE public.hss_service_orders
+ ADD CONSTRAINT chk_hss_service_orders_dispatch_status
+ CHECK (
+ dispatch_status IN ('pending', 'dispatching', 'assigned', 'failed')
+ ) NOT VALID;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'chk_hss_service_orders_service_lat'
+ AND conrelid = 'public.hss_service_orders'::regclass
+ ) THEN
+ ALTER TABLE public.hss_service_orders
+ ADD CONSTRAINT chk_hss_service_orders_service_lat
+ CHECK (
+ service_lat IS NULL OR service_lat BETWEEN -90 AND 90
+ ) NOT VALID;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'chk_hss_service_orders_service_lng'
+ AND conrelid = 'public.hss_service_orders'::regclass
+ ) THEN
+ ALTER TABLE public.hss_service_orders
+ ADD CONSTRAINT chk_hss_service_orders_service_lng
+ CHECK (
+ service_lng IS NULL OR service_lng BETWEEN -180 AND 180
+ ) NOT VALID;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'chk_hss_service_orders_schedule_time'
+ AND conrelid = 'public.hss_service_orders'::regclass
+ ) THEN
+ ALTER TABLE public.hss_service_orders
+ ADD CONSTRAINT chk_hss_service_orders_schedule_time
+ CHECK (
+ scheduled_start_at IS NULL
+ OR scheduled_end_at IS NULL
+ OR scheduled_end_at > scheduled_start_at
+ ) NOT VALID;
+ END IF;
+END;
+$$;
+
+
+-- =====================================================================================
+-- 3. 补充服务人员派单能力字段
+-- =====================================================================================
+
+ALTER TABLE public.ml_delivery_staff
+ ADD COLUMN IF NOT EXISTS current_lat NUMERIC(10,7),
+ ADD COLUMN IF NOT EXISTS current_lng NUMERIC(10,7),
+
+ -- 允许接单的最大直线距离,单位:公里
+ ADD COLUMN IF NOT EXISTS dispatch_radius_km NUMERIC(8,2) NOT NULL DEFAULT 20,
+
+ -- 用于公平派单,优先选择更久没有接到任务的人
+ ADD COLUMN IF NOT EXISTS last_dispatched_at TIMESTAMPTZ;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'chk_ml_delivery_staff_dispatch_radius'
+ AND conrelid = 'public.ml_delivery_staff'::regclass
+ ) THEN
+ ALTER TABLE public.ml_delivery_staff
+ ADD CONSTRAINT chk_ml_delivery_staff_dispatch_radius
+ CHECK (
+ dispatch_radius_km > 0 AND dispatch_radius_km <= 200
+ ) NOT VALID;
+ END IF;
+END;
+$$;
+
+
+-- =====================================================================================
+-- 4. 补充派单记录字段
+-- =====================================================================================
+
+ALTER TABLE public.hss_service_assignments
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
+
+ -- 派单当时服务人员与用户之间的计算距离
+ ADD COLUMN IF NOT EXISTS dispatch_distance_km NUMERIC(8,3),
+
+ -- 派单时使用的资质要求快照
+ ADD COLUMN IF NOT EXISTS qualification_code_snapshot TEXT;
+
+
+-- =====================================================================================
+-- 5. 补充订单日志触发者字段
+-- 原表 operator_id 可能已有 NOT NULL 约束,所以继续保留兼容写入;
+-- 新增 triggered_by_user_id 用于明确“由哪个消费者触发系统派单”。
+-- =====================================================================================
+
+ALTER TABLE public.hss_service_order_status_logs
+ ADD COLUMN IF NOT EXISTS triggered_by_user_id UUID;
+
+
+-- =====================================================================================
+-- 6. 服务人员资质表
+-- 如果该表此前已经存在,会补充以下标准字段。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.hc_worker_qualifications (
+ id TEXT PRIMARY KEY,
+ staff_id TEXT NOT NULL,
+ qualification_code TEXT NOT NULL,
+ qualification_name TEXT NOT NULL DEFAULT '',
+ certificate_no TEXT NOT NULL DEFAULT '',
+ status SMALLINT NOT NULL DEFAULT 1,
+ valid_from TIMESTAMPTZ,
+ valid_until TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+);
+
+ALTER TABLE public.hc_worker_qualifications
+ ADD COLUMN IF NOT EXISTS staff_id TEXT,
+ ADD COLUMN IF NOT EXISTS qualification_code TEXT,
+ ADD COLUMN IF NOT EXISTS qualification_name TEXT NOT NULL DEFAULT '',
+ ADD COLUMN IF NOT EXISTS certificate_no TEXT NOT NULL DEFAULT '',
+ ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 1,
+ ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS valid_until TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+
+ALTER TABLE public.hc_worker_qualifications ENABLE ROW LEVEL SECURITY;
+
+REVOKE ALL ON TABLE public.hc_worker_qualifications FROM anon;
+REVOKE ALL ON TABLE public.hc_worker_qualifications FROM authenticated;
+
+CREATE INDEX IF NOT EXISTS idx_hc_worker_qualifications_dispatch_match
+ON public.hc_worker_qualifications (
+ staff_id,
+ qualification_code,
+ status,
+ valid_until
+)
+WHERE deleted_at IS NULL;
+
+
+-- =====================================================================================
+-- 7. 新增派单尝试日志表
+-- 专门记录成功或失败派单,不强行复用订单状态日志。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS public.hss_service_dispatch_attempt_logs (
+ id TEXT PRIMARY KEY,
+ order_id TEXT NOT NULL,
+ requested_by_user_id UUID,
+ selected_staff_id TEXT,
+ selected_station_id TEXT,
+ success BOOLEAN NOT NULL DEFAULT FALSE,
+ result_code TEXT NOT NULL,
+ result_message TEXT NOT NULL,
+ retryable BOOLEAN NOT NULL DEFAULT FALSE,
+ filters_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.hss_service_dispatch_attempt_logs ENABLE ROW LEVEL SECURITY;
+
+REVOKE ALL ON TABLE public.hss_service_dispatch_attempt_logs FROM anon;
+REVOKE ALL ON TABLE public.hss_service_dispatch_attempt_logs FROM authenticated;
+
+CREATE INDEX IF NOT EXISTS idx_hss_dispatch_attempt_logs_order_time
+ON public.hss_service_dispatch_attempt_logs (
+ order_id,
+ created_at DESC
+);
+
+
+-- =====================================================================================
+-- 8. 派单性能索引
+-- =====================================================================================
+
+CREATE INDEX IF NOT EXISTS idx_hss_service_orders_dispatch_query
+ON public.hss_service_orders (
+ user_id,
+ payment_status,
+ status,
+ dispatch_status,
+ created_at DESC
+)
+WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_ml_delivery_staff_dispatch_candidate
+ON public.ml_delivery_staff (
+ online_status,
+ status,
+ is_active,
+ station_id,
+ last_dispatched_at
+)
+WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_hss_service_assignments_staff_active
+ON public.hss_service_assignments (
+ staff_id,
+ status,
+ assigned_at DESC
+)
+WHERE deleted_at IS NULL
+ AND status IN ('assigned', 'accepted', 'departed', 'arrived', 'serving', 'in_service');
+
+
+-- =====================================================================================
+-- 9. 创建活动派单唯一索引前,先检查是否已有脏数据
+-- 如果同一订单已经存在多个有效派单,会中止脚本,避免自动误删业务数据。
+-- =====================================================================================
+
+DO $$
+DECLARE
+ v_duplicate_orders TEXT;
+BEGIN
+ SELECT string_agg(t.order_id::text, ', ')
+ INTO v_duplicate_orders
+ FROM (
+ SELECT order_id
+ FROM public.hss_service_assignments
+ WHERE deleted_at IS NULL
+ AND status IN ('assigned', 'accepted', 'departed', 'arrived', 'serving', 'in_service')
+ GROUP BY order_id
+ HAVING COUNT(*) > 1
+ LIMIT 20
+ ) t;
+
+ IF v_duplicate_orders IS NOT NULL THEN
+ RAISE EXCEPTION
+ '存在同一订单的多条有效派单记录,请先人工核对并关闭重复记录后重新执行。订单ID: %',
+ v_duplicate_orders;
+ END IF;
+END;
+$$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_hss_service_assignments_active_order
+ON public.hss_service_assignments (order_id)
+WHERE deleted_at IS NULL
+ AND status IN ('assigned', 'accepted', 'departed', 'arrived', 'serving', 'in_service');
+
+
+-- =====================================================================================
+-- 10. 创建安全自动派单 RPC
+-- =====================================================================================
+
+CREATE OR REPLACE FUNCTION public.rpc_homecare_auto_dispatch(
+ p_order_id TEXT DEFAULT NULL
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $function$
+DECLARE
+ v_now TIMESTAMPTZ := clock_timestamp();
+
+ v_current_user_id UUID;
+ v_order public.hss_service_orders%ROWTYPE;
+
+ v_existing_assignment RECORD;
+ v_candidate RECORD;
+ v_candidate_found BOOLEAN := FALSE;
+
+ v_assignment_id TEXT;
+ v_status_log_id TEXT;
+ v_attempt_log_id TEXT;
+
+ v_failure_code TEXT;
+ v_failure_message TEXT;
+ v_retryable BOOLEAN := FALSE;
+BEGIN
+ -- =========================================================================
+ -- A. 基础参数与身份校验
+ -- =========================================================================
+
+ IF p_order_id IS NULL OR btrim(p_order_id) = '' THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'ORDER_ID_REQUIRED',
+ 'message', '订单ID不能为空',
+ 'display_type', 'toast',
+ 'retryable', FALSE
+ );
+ END IF;
+
+ IF auth.uid() IS NULL THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'UNAUTHENTICATED',
+ 'message', '登录状态已失效,请重新登录后再操作',
+ 'display_type', 'modal',
+ 'retryable', FALSE
+ );
+ END IF;
+
+ SELECT u.id
+ INTO v_current_user_id
+ FROM public.ak_users u
+ WHERE u.auth_id = auth.uid()
+ LIMIT 1;
+
+ IF v_current_user_id IS NULL THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'USER_PROFILE_NOT_FOUND',
+ 'message', '当前账户信息异常,请重新登录或联系客服',
+ 'display_type', 'modal',
+ 'retryable', FALSE
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- B. 锁定订单,避免同一个订单被重复派单
+ -- =========================================================================
+
+ SELECT o.*
+ INTO v_order
+ FROM public.hss_service_orders o
+ WHERE o.id = p_order_id
+ AND o.deleted_at IS NULL
+ FOR UPDATE OF o;
+
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'ORDER_NOT_FOUND',
+ 'message', '订单不存在或已被删除',
+ 'display_type', 'modal',
+ 'retryable', FALSE
+ );
+ END IF;
+
+ IF v_order.user_id IS DISTINCT FROM v_current_user_id THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'ORDER_ACCESS_DENIED',
+ 'message', '您无权操作该订单',
+ 'display_type', 'modal',
+ 'retryable', FALSE
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- C. 幂等处理:订单已经派单成功时,不重复插入记录
+ -- =========================================================================
+
+ IF v_order.status = 'assigned'
+ AND v_order.current_assignment_id IS NOT NULL THEN
+
+ RETURN jsonb_build_object(
+ 'success', TRUE,
+ 'code', 'ALREADY_ASSIGNED',
+ 'message', '订单已完成派单',
+ 'display_type', 'none',
+ 'retryable', FALSE,
+ 'dispatch_status', 'assigned',
+ 'order_id', p_order_id,
+ 'assignment_id', v_order.current_assignment_id,
+ 'staff_id', v_order.current_staff_id
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- D. 支付与订单状态校验
+ -- payment_status:
+ -- 1 = 待支付
+ -- 2 = 已支付
+ -- 3 = 已退款
+ -- 4 = 支付失败
+ -- 5 = 支付超时关闭
+ -- =========================================================================
+
+ IF COALESCE(v_order.payment_status, 1) <> 2 THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'ORDER_NOT_PAID',
+ 'message', '订单尚未完成支付,暂不能安排服务人员',
+ 'display_type', 'modal',
+ 'retryable', FALSE,
+ 'dispatch_status', COALESCE(v_order.dispatch_status, 'pending'),
+ 'order_id', p_order_id
+ );
+ END IF;
+
+ IF v_order.status <> 'created' THEN
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'ORDER_STATUS_NOT_DISPATCHABLE',
+ 'message', '当前订单状态不允许重新派单',
+ 'display_type', 'modal',
+ 'retryable', FALSE,
+ 'dispatch_status', COALESCE(v_order.dispatch_status, 'pending'),
+ 'order_id', p_order_id
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- E. 兼容历史数据:若订单已存在有效派单记录,但订单主表未同步,则修复主表
+ -- =========================================================================
+
+ SELECT
+ a.id,
+ a.staff_id,
+ a.station_id
+ INTO v_existing_assignment
+ FROM public.hss_service_assignments a
+ WHERE a.order_id = p_order_id
+ AND a.deleted_at IS NULL
+ AND a.status IN ('assigned', 'accepted', 'departed', 'arrived', 'serving', 'in_service')
+ ORDER BY a.assigned_at DESC
+ LIMIT 1;
+
+ IF FOUND THEN
+ UPDATE public.hss_service_orders
+ SET status = 'assigned',
+ dispatch_status = 'assigned',
+ current_assignment_id = v_existing_assignment.id,
+ current_staff_id = v_existing_assignment.staff_id,
+ dispatch_error_code = NULL,
+ dispatch_error_message = NULL,
+ dispatch_failed_at = NULL,
+ updated_at = v_now
+ WHERE id = p_order_id;
+
+ RETURN jsonb_build_object(
+ 'success', TRUE,
+ 'code', 'ALREADY_ASSIGNED_RECOVERED',
+ 'message', '订单已存在有效派单记录,系统已完成状态同步',
+ 'display_type', 'none',
+ 'retryable', FALSE,
+ 'dispatch_status', 'assigned',
+ 'order_id', p_order_id,
+ 'assignment_id', v_existing_assignment.id,
+ 'staff_id', v_existing_assignment.staff_id,
+ 'station_id', COALESCE(v_existing_assignment.station_id::text, '')
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- F. 进入派单中状态
+ -- =========================================================================
+
+ UPDATE public.hss_service_orders
+ SET dispatch_status = 'dispatching',
+ dispatch_attempt_count = COALESCE(dispatch_attempt_count, 0) + 1,
+ dispatch_error_code = NULL,
+ dispatch_error_message = NULL,
+ dispatch_failed_at = NULL,
+ updated_at = v_now
+ WHERE id = p_order_id;
+
+
+ -- =========================================================================
+ -- G. 查找可派单服务人员
+ --
+ -- 筛选规则:
+ -- 1. 正常、启用、在线、绑定用户
+ -- 2. 订单指定站点时,必须属于该站点
+ -- 3. 订单指定资质时,必须持有有效资质
+ -- 4. 订单包含经纬度时,必须在服务半径内
+ -- 5. 不得与当前有效任务发生服务时间冲突
+ -- 6. 锁住被选中的服务人员,防止并发订单同时选中同一人
+ -- =========================================================================
+
+ SELECT
+ s.id,
+ s.station_id,
+ distance_calc.distance_km
+ INTO v_candidate
+ FROM public.ml_delivery_staff s
+ CROSS JOIN LATERAL (
+ SELECT
+ CASE
+ WHEN v_order.service_lat IS NULL
+ OR v_order.service_lng IS NULL
+ OR s.current_lat IS NULL
+ OR s.current_lng IS NULL
+ THEN NULL
+ ELSE (
+ 6371 * 2 * asin(
+ sqrt(
+ power(
+ sin(
+ radians((s.current_lat - v_order.service_lat)::DOUBLE PRECISION) / 2
+ ),
+ 2
+ )
+ +
+ cos(radians(v_order.service_lat::DOUBLE PRECISION))
+ * cos(radians(s.current_lat::DOUBLE PRECISION))
+ * power(
+ sin(
+ radians((s.current_lng - v_order.service_lng)::DOUBLE PRECISION) / 2
+ ),
+ 2
+ )
+ )
+ )
+ )::NUMERIC(8,3)
+ END AS distance_km
+ ) distance_calc
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+
+ AND (
+ v_order.dispatch_station_id IS NULL
+ OR s.station_id::TEXT = v_order.dispatch_station_id
+ )
+
+ AND (
+ v_order.required_qualification_code IS NULL
+ OR EXISTS (
+ SELECT 1
+ FROM public.hc_worker_qualifications q
+ WHERE q.staff_id::TEXT = s.id::TEXT
+ AND q.qualification_code = v_order.required_qualification_code
+ AND q.deleted_at IS NULL
+ AND q.status::TEXT IN ('1', 'active', 'approved', 'valid')
+ AND (q.valid_from IS NULL OR q.valid_from <= v_now)
+ AND (q.valid_until IS NULL OR q.valid_until >= v_now)
+ )
+ )
+
+ AND (
+ v_order.service_lat IS NULL
+ OR v_order.service_lng IS NULL
+ OR (
+ s.current_lat IS NOT NULL
+ AND s.current_lng IS NOT NULL
+ AND distance_calc.distance_km <= COALESCE(s.dispatch_radius_km, 20)
+ )
+ )
+
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.hss_service_assignments existing_assignment
+ JOIN public.hss_service_orders existing_order
+ ON existing_order.id = existing_assignment.order_id
+ WHERE existing_assignment.staff_id::TEXT = s.id::TEXT
+ AND existing_assignment.deleted_at IS NULL
+ AND existing_assignment.status IN (
+ 'assigned',
+ 'accepted',
+ 'departed',
+ 'arrived',
+ 'serving',
+ 'in_service'
+ )
+ AND (
+ v_order.scheduled_start_at IS NULL
+ OR v_order.scheduled_end_at IS NULL
+ OR existing_order.scheduled_start_at IS NULL
+ OR existing_order.scheduled_end_at IS NULL
+ OR tstzrange(
+ existing_order.scheduled_start_at,
+ existing_order.scheduled_end_at,
+ '[)'
+ ) && tstzrange(
+ v_order.scheduled_start_at,
+ v_order.scheduled_end_at,
+ '[)'
+ )
+ )
+ )
+
+ ORDER BY
+ CASE
+ WHEN v_order.dispatch_station_id IS NOT NULL
+ AND s.station_id::TEXT = v_order.dispatch_station_id
+ THEN 0
+ ELSE 1
+ END,
+ distance_calc.distance_km ASC NULLS LAST,
+ COALESCE(s.last_dispatched_at, '-infinity'::TIMESTAMPTZ) ASC,
+ COALESCE(s.updated_at, s.created_at) DESC
+
+ LIMIT 1
+ FOR UPDATE OF s SKIP LOCKED;
+
+ v_candidate_found := FOUND;
+
+
+ -- =========================================================================
+ -- H. 没有找到候选人员时,识别具体失败原因
+ -- =========================================================================
+
+ IF NOT v_candidate_found THEN
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ ) THEN
+ v_failure_code := 'NO_ONLINE_STAFF';
+ v_failure_message := '当前暂无在线服务人员,请稍后重新派单';
+ v_retryable := TRUE;
+
+ ELSIF v_order.dispatch_station_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ AND s.station_id::TEXT = v_order.dispatch_station_id
+ ) THEN
+ v_failure_code := 'NO_STAFF_IN_SERVICE_STATION';
+ v_failure_message := '当前服务区域暂无可派服务人员,请稍后重试或联系客服';
+ v_retryable := TRUE;
+
+ ELSIF v_order.required_qualification_code IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff s
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ AND (
+ v_order.dispatch_station_id IS NULL
+ OR s.station_id::TEXT = v_order.dispatch_station_id
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM public.hc_worker_qualifications q
+ WHERE q.staff_id::TEXT = s.id::TEXT
+ AND q.qualification_code = v_order.required_qualification_code
+ AND q.deleted_at IS NULL
+ AND q.status::TEXT IN ('1', 'active', 'approved', 'valid')
+ AND (q.valid_from IS NULL OR q.valid_from <= v_now)
+ AND (q.valid_until IS NULL OR q.valid_until >= v_now)
+ )
+ ) THEN
+ v_failure_code := 'NO_QUALIFIED_STAFF';
+ v_failure_message := '当前暂无具备该服务资质的服务人员,请稍后重试或联系客服';
+ v_retryable := TRUE;
+
+ ELSIF v_order.service_lat IS NOT NULL
+ AND v_order.service_lng IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.ml_delivery_staff s
+ CROSS JOIN LATERAL (
+ SELECT
+ (
+ 6371 * 2 * asin(
+ sqrt(
+ power(
+ sin(
+ radians((s.current_lat - v_order.service_lat)::DOUBLE PRECISION) / 2
+ ),
+ 2
+ )
+ +
+ cos(radians(v_order.service_lat::DOUBLE PRECISION))
+ * cos(radians(s.current_lat::DOUBLE PRECISION))
+ * power(
+ sin(
+ radians((s.current_lng - v_order.service_lng)::DOUBLE PRECISION) / 2
+ ),
+ 2
+ )
+ )
+ )
+ )::NUMERIC(8,3) AS distance_km
+ ) distance_calc
+ WHERE s.deleted_at IS NULL
+ AND s.status = 1
+ AND COALESCE(s.is_active, TRUE) = TRUE
+ AND s.online_status = 'online'
+ AND s.uid IS NOT NULL
+ AND s.current_lat IS NOT NULL
+ AND s.current_lng IS NOT NULL
+ AND (
+ v_order.dispatch_station_id IS NULL
+ OR s.station_id::TEXT = v_order.dispatch_station_id
+ )
+ AND (
+ v_order.required_qualification_code IS NULL
+ OR EXISTS (
+ SELECT 1
+ FROM public.hc_worker_qualifications q
+ WHERE q.staff_id::TEXT = s.id::TEXT
+ AND q.qualification_code = v_order.required_qualification_code
+ AND q.deleted_at IS NULL
+ AND q.status::TEXT IN ('1', 'active', 'approved', 'valid')
+ AND (q.valid_from IS NULL OR q.valid_from <= v_now)
+ AND (q.valid_until IS NULL OR q.valid_until >= v_now)
+ )
+ )
+ AND distance_calc.distance_km <= COALESCE(s.dispatch_radius_km, 20)
+ ) THEN
+ v_failure_code := 'NO_NEARBY_STAFF';
+ v_failure_message := '附近暂无可上门服务人员,请稍后重试或联系客服';
+ v_retryable := TRUE;
+
+ ELSE
+ v_failure_code := 'ALL_ELIGIBLE_STAFF_BUSY';
+ v_failure_message := '当前可服务人员均在忙,请稍后重新派单';
+ v_retryable := TRUE;
+ END IF;
+
+
+ UPDATE public.hss_service_orders
+ SET dispatch_status = 'failed',
+ dispatch_error_code = v_failure_code,
+ dispatch_error_message = v_failure_message,
+ dispatch_failed_at = v_now,
+ updated_at = v_now
+ WHERE id = p_order_id;
+
+ v_attempt_log_id :=
+ 'dalog-'
+ || floor(extract(epoch FROM v_now) * 1000)::BIGINT::TEXT
+ || '-'
+ || upper(substr(md5(p_order_id || ':' || random()::TEXT), 1, 10));
+
+ INSERT INTO public.hss_service_dispatch_attempt_logs (
+ id,
+ order_id,
+ requested_by_user_id,
+ selected_staff_id,
+ selected_station_id,
+ success,
+ result_code,
+ result_message,
+ retryable,
+ filters_snapshot,
+ created_at
+ ) VALUES (
+ v_attempt_log_id,
+ p_order_id,
+ v_current_user_id,
+ NULL,
+ v_order.dispatch_station_id,
+ FALSE,
+ v_failure_code,
+ v_failure_message,
+ v_retryable,
+ jsonb_build_object(
+ 'required_qualification_code', v_order.required_qualification_code,
+ 'dispatch_station_id', v_order.dispatch_station_id,
+ 'service_lat', v_order.service_lat,
+ 'service_lng', v_order.service_lng,
+ 'scheduled_start_at', v_order.scheduled_start_at,
+ 'scheduled_end_at', v_order.scheduled_end_at
+ ),
+ v_now
+ );
+
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', v_failure_code,
+ 'message', v_failure_message,
+ 'display_type', 'modal',
+ 'retryable', v_retryable,
+ 'dispatch_status', 'failed',
+ 'order_id', p_order_id
+ );
+ END IF;
+
+
+ -- =========================================================================
+ -- I. 派单成功:创建派单记录
+ -- =========================================================================
+
+ v_assignment_id :=
+ 'sa-'
+ || floor(extract(epoch FROM v_now) * 1000)::BIGINT::TEXT
+ || '-'
+ || upper(substr(md5(p_order_id || ':' || v_candidate.id::TEXT || ':' || random()::TEXT), 1, 10));
+
+ INSERT INTO public.hss_service_assignments (
+ id,
+ order_id,
+ staff_id,
+ station_id,
+ status,
+ assigned_at,
+ dispatch_distance_km,
+ qualification_code_snapshot,
+ created_at,
+ updated_at
+ ) VALUES (
+ v_assignment_id,
+ p_order_id,
+ v_candidate.id,
+ v_candidate.station_id,
+ 'assigned',
+ v_now,
+ v_candidate.distance_km,
+ v_order.required_qualification_code,
+ v_now,
+ v_now
+ );
+
+
+ -- =========================================================================
+ -- J. 更新订单与服务人员状态
+ -- =========================================================================
+
+ UPDATE public.hss_service_orders
+ SET status = 'assigned',
+ dispatch_status = 'assigned',
+ current_assignment_id = v_assignment_id,
+ current_staff_id = v_candidate.id,
+ dispatch_error_code = NULL,
+ dispatch_error_message = NULL,
+ dispatch_failed_at = NULL,
+ updated_at = v_now
+ WHERE id = p_order_id;
+
+ UPDATE public.ml_delivery_staff
+ SET last_dispatched_at = v_now,
+ updated_at = v_now
+ WHERE id = v_candidate.id;
+
+
+ -- =========================================================================
+ -- K. 写入订单状态日志
+ -- =========================================================================
+
+ v_status_log_id :=
+ 'slog-'
+ || floor(extract(epoch FROM v_now) * 1000)::BIGINT::TEXT
+ || '-'
+ || upper(substr(md5(p_order_id || ':status:' || random()::TEXT), 1, 10));
+
+ INSERT INTO public.hss_service_order_status_logs (
+ id,
+ order_id,
+ from_status,
+ to_status,
+ operator_id,
+ operator_role,
+ triggered_by_user_id,
+ remark,
+ created_at
+ ) VALUES (
+ v_status_log_id,
+ p_order_id,
+ 'created',
+ 'assigned',
+ v_current_user_id,
+ 'system',
+ v_current_user_id,
+ '消费者支付成功后触发系统自动派单',
+ v_now
+ );
+
+
+ -- =========================================================================
+ -- L. 写入派单成功尝试日志
+ -- =========================================================================
+
+ v_attempt_log_id :=
+ 'dalog-'
+ || floor(extract(epoch FROM v_now) * 1000)::BIGINT::TEXT
+ || '-'
+ || upper(substr(md5(p_order_id || ':success:' || random()::TEXT), 1, 10));
+
+ INSERT INTO public.hss_service_dispatch_attempt_logs (
+ id,
+ order_id,
+ requested_by_user_id,
+ selected_staff_id,
+ selected_station_id,
+ success,
+ result_code,
+ result_message,
+ retryable,
+ filters_snapshot,
+ created_at
+ ) VALUES (
+ v_attempt_log_id,
+ p_order_id,
+ v_current_user_id,
+ v_candidate.id::TEXT,
+ COALESCE(v_candidate.station_id::TEXT, ''),
+ TRUE,
+ 'DISPATCH_ASSIGNED',
+ '系统已为您匹配服务人员',
+ FALSE,
+ jsonb_build_object(
+ 'required_qualification_code', v_order.required_qualification_code,
+ 'dispatch_station_id', v_order.dispatch_station_id,
+ 'service_lat', v_order.service_lat,
+ 'service_lng', v_order.service_lng,
+ 'scheduled_start_at', v_order.scheduled_start_at,
+ 'scheduled_end_at', v_order.scheduled_end_at,
+ 'dispatch_distance_km', v_candidate.distance_km
+ ),
+ v_now
+ );
+
+
+ -- =========================================================================
+ -- M. 返回派单成功结果
+ -- =========================================================================
+
+ RETURN jsonb_build_object(
+ 'success', TRUE,
+ 'code', 'DISPATCH_ASSIGNED',
+ 'message', '系统已为您匹配服务人员',
+ 'display_type', 'none',
+ 'retryable', FALSE,
+ 'dispatch_status', 'assigned',
+ 'order_id', p_order_id,
+ 'assignment_id', v_assignment_id,
+ 'staff_id', v_candidate.id,
+ 'station_id', COALESCE(v_candidate.station_id::TEXT, ''),
+ 'dispatch_distance_km', v_candidate.distance_km
+ );
+
+
+EXCEPTION
+ WHEN unique_violation THEN
+ -- 极端并发场景下,唯一索引阻止重复派单后,将结果转换为前端可处理的返回值。
+
+ UPDATE public.hss_service_orders
+ SET dispatch_status = 'failed',
+ dispatch_error_code = 'DISPATCH_CONFLICT_RETRY',
+ dispatch_error_message = '派单请求较多,请重新尝试',
+ dispatch_failed_at = clock_timestamp(),
+ updated_at = clock_timestamp()
+ WHERE id = p_order_id
+ AND status = 'created';
+
+ RETURN jsonb_build_object(
+ 'success', FALSE,
+ 'code', 'DISPATCH_CONFLICT_RETRY',
+ 'message', '派单请求较多,请重新尝试',
+ 'display_type', 'modal',
+ 'retryable', TRUE,
+ 'dispatch_status', 'failed',
+ 'order_id', p_order_id
+ );
+END;
+$function$;
+
+
+-- =====================================================================================
+-- 11. 限制 RPC 调用权限
+-- =====================================================================================
+
+REVOKE ALL ON FUNCTION public.rpc_homecare_auto_dispatch(TEXT) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.rpc_homecare_auto_dispatch(TEXT) FROM anon;
+
+GRANT EXECUTE ON FUNCTION public.rpc_homecare_auto_dispatch(TEXT) TO authenticated;
+
+
+COMMENT ON FUNCTION public.rpc_homecare_auto_dispatch(TEXT)
+IS '居家服务订单支付成功后的安全自动派单RPC:校验用户、支付、状态、资质、站点、距离与时间冲突,并返回结构化失败原因';
+
+
+COMMIT;
\ No newline at end of file
diff --git a/mall_sql/migrations/20260601_hss_service_package_pricing_v1.sql b/mall_sql/migrations/20260601_hss_service_package_pricing_v1.sql
new file mode 100644
index 00000000..80368b88
--- /dev/null
+++ b/mall_sql/migrations/20260601_hss_service_package_pricing_v1.sql
@@ -0,0 +1,116 @@
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS public.hss_service_packages (
+ id TEXT PRIMARY KEY,
+ service_id TEXT NOT NULL REFERENCES public.hss_service_catalog(id) ON DELETE RESTRICT,
+ package_name TEXT NOT NULL,
+ package_desc TEXT NOT NULL DEFAULT '',
+ duration_minutes INTEGER NOT NULL DEFAULT 0,
+ duration_text TEXT NOT NULL DEFAULT '',
+ price NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ list_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ is_default BOOLEAN NOT NULL DEFAULT false,
+ sort_no INTEGER NOT NULL DEFAULT 0,
+ status SMALLINT NOT NULL DEFAULT 1,
+ effective_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ,
+ data_source TEXT NOT NULL DEFAULT 'manual',
+ seed_batch_no TEXT NOT NULL DEFAULT '',
+ remark TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_hss_service_packages_service_status_sort
+ ON public.hss_service_packages(service_id, status, sort_no)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_hss_service_packages_effective_window
+ ON public.hss_service_packages(effective_at, expires_at)
+ WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE FUNCTION public.tg_hss_service_packages_updated_at()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_hss_service_packages_updated_at ON public.hss_service_packages;
+CREATE TRIGGER trg_hss_service_packages_updated_at
+BEFORE UPDATE ON public.hss_service_packages
+FOR EACH ROW
+EXECUTE FUNCTION public.tg_hss_service_packages_updated_at();
+
+ALTER TABLE public.hss_service_packages ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS hss_service_packages_public_select_active ON public.hss_service_packages;
+CREATE POLICY hss_service_packages_public_select_active
+ ON public.hss_service_packages
+ FOR SELECT
+ USING (
+ deleted_at IS NULL
+ AND status = 1
+ AND (effective_at IS NULL OR effective_at <= now())
+ AND (expires_at IS NULL OR expires_at > now())
+ );
+
+ALTER TABLE public.hss_service_orders
+ ADD COLUMN IF NOT EXISTS service_package_id TEXT REFERENCES public.hss_service_packages(id) ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS pricing_snapshot_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ ADD COLUMN IF NOT EXISTS original_amount NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS payable_amount NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS total_amount NUMERIC(10, 2) NOT NULL DEFAULT 0;
+
+CREATE INDEX IF NOT EXISTS idx_hss_service_orders_service_package_id
+ ON public.hss_service_orders(service_package_id)
+ WHERE deleted_at IS NULL;
+
+UPDATE public.hss_service_orders
+SET
+ pricing_snapshot_json = CASE
+ WHEN pricing_snapshot_json = '{}'::jsonb THEN jsonb_build_object(
+ 'service_id', service_id,
+ 'service_name', service_name,
+ 'package_id', NULL,
+ 'package_name', service_name,
+ 'price', COALESCE((service_snapshot_json ->> 'price')::NUMERIC, 0),
+ 'data_source', 'legacy_service_snapshot',
+ 'remark', '历史订单由 service_snapshot_json.price 回填'
+ )
+ ELSE pricing_snapshot_json
+ END,
+ original_amount = CASE
+ WHEN original_amount <= 0 THEN COALESCE((service_snapshot_json ->> 'price')::NUMERIC, 0)
+ ELSE original_amount
+ END,
+ payable_amount = CASE
+ WHEN payable_amount <= 0 THEN COALESCE((service_snapshot_json ->> 'price')::NUMERIC, 0)
+ ELSE payable_amount
+ END,
+ total_amount = CASE
+ WHEN total_amount <= 0 THEN COALESCE((service_snapshot_json ->> 'price')::NUMERIC, 0)
+ ELSE total_amount
+ END,
+ updated_at = now()
+WHERE deleted_at IS NULL
+ AND (
+ pricing_snapshot_json = '{}'::jsonb
+ OR original_amount <= 0
+ OR payable_amount <= 0
+ OR total_amount <= 0
+ );
+
+COMMENT ON TABLE public.hss_service_packages IS '居家服务正式套餐/报价表';
+COMMENT ON COLUMN public.hss_service_packages.data_source IS 'manual/dev_seed/prod_import 等数据来源标识';
+COMMENT ON COLUMN public.hss_service_packages.seed_batch_no IS '测试种子批次号,便于上线前清理';
+COMMENT ON COLUMN public.hss_service_orders.service_package_id IS '下单时冻结的正式套餐 ID';
+COMMENT ON COLUMN public.hss_service_orders.pricing_snapshot_json IS '订单创建时冻结的套餐/报价快照';
+COMMENT ON COLUMN public.hss_service_orders.payable_amount IS '订单应付金额快照';
+COMMENT ON COLUMN public.hss_service_orders.total_amount IS '服务订单总金额快照';
+
+COMMIT;
\ No newline at end of file
diff --git a/mall_sql/schemas/complete_mall_database_fixed.sql b/mall_sql/schemas/complete_mall_database_fixed.sql
new file mode 100644
index 00000000..2552dd16
--- /dev/null
+++ b/mall_sql/schemas/complete_mall_database_fixed.sql
@@ -0,0 +1,1377 @@
+-- =====================================================================================
+-- 电商商城系统完整数据库设计(PostgreSQL + Supabase)
+-- 表名前缀: ml_ (mall)
+-- 复用主表 ak_users (用户主表)
+-- 包含: 表结构、索引、触发器、RLS策略、视图、函数
+-- =====================================================================================
+
+-- =====================================================================================
+-- 1. 基础配置和扩展
+-- =====================================================================================
+
+-- 启用必要的扩展
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+CREATE EXTENSION IF NOT EXISTS "pg_stat_statements";
+CREATE EXTENSION IF NOT EXISTS "btree_gin";
+
+-- =====================================================================================
+-- 2. 用户扩展表
+-- =====================================================================================
+
+-- 商城用户扩展信息表
+CREATE TABLE public.ml_user_profiles (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ status INTEGER DEFAULT 1 NOT NULL, -- 1:正常 2:冻结 3:注销 4:待审核
+ real_name VARCHAR(100), -- 真实姓名
+ id_card VARCHAR(32), -- 身份证号
+ business_license VARCHAR(100), -- 营业执照号
+ credit_score INTEGER DEFAULT 100, -- 信用分数 0-1000
+ verification_status INTEGER DEFAULT 0, -- 认证状态0:未认证1:已认证2:认证失败
+ verification_data JSONB DEFAULT '{}', -- 认证相关数据
+ preferences JSONB DEFAULT '{}', -- 用户偏好设置
+ emergency_contact VARCHAR(200), -- 紧急联系人
+ service_areas JSONB, -- 服务区域(配送员)
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_user_status CHECK (status IN (1,2,3,4)),
+ CONSTRAINT chk_ml_verification_status CHECK (verification_status IN (0,1,2)),
+ CONSTRAINT chk_ml_credit_score CHECK (credit_score >= 0 AND credit_score <= 1000)
+);
+
+COMMENT ON TABLE public.ml_user_profiles IS '商城用户扩展信息表';
+COMMENT ON COLUMN public.ml_user_profiles.status IS '用户状态:1正常 2冻结 3注销 4待审核';
+
+-- 用户地址表
+CREATE TABLE public.ml_user_addresses (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ receiver_name VARCHAR(100) NOT NULL,
+ receiver_phone VARCHAR(32) NOT NULL,
+ province VARCHAR(100) NOT NULL,
+ city VARCHAR(100) NOT NULL,
+ district VARCHAR(100) NOT NULL,
+ street VARCHAR(200),
+ address_detail TEXT NOT NULL,
+ postal_code VARCHAR(16),
+ is_default BOOLEAN DEFAULT FALSE,
+ label VARCHAR(50), -- home/office/school/other
+ latitude DECIMAL(10,7),
+ longitude DECIMAL(10,7),
+ delivery_instructions TEXT,
+ business_hours VARCHAR(100),
+ status INTEGER DEFAULT 1, -- 1:正常 2:禁用
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_address_status CHECK (status IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_user_addresses IS '用户地址表;
+
+-- =====================================================================================
+-- 3. 商品管理模块
+-- =====================================================================================
+
+-- 商品分类表
+CREATE TABLE public.ml_categories (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ parent_id UUID REFERENCES public.ml_categories(id),
+ name VARCHAR(200) NOT NULL,
+ slug VARCHAR(200) UNIQUE,
+ description TEXT,
+ icon_url TEXT,
+ banner_url TEXT,
+ sort_order INTEGER DEFAULT 0,
+ level INTEGER DEFAULT 1,
+ path TEXT[], -- 分类路径
+ is_active BOOLEAN DEFAULT TRUE,
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_categories IS '商品分类表;
+
+-- 品牌表
+CREATE TABLE public.ml_brands (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ name VARCHAR(200) NOT NULL,
+ logo_url TEXT,
+ description TEXT,
+ website VARCHAR(500),
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_brands IS '品牌表;
+
+-- 商品表
+CREATE TABLE public.ml_products (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+ category_id UUID NOT NULL REFERENCES public.ml_categories(id),
+ brand_id UUID REFERENCES public.ml_brands(id),
+ product_code VARCHAR(100) UNIQUE NOT NULL,
+ name VARCHAR(500) NOT NULL,
+ subtitle VARCHAR(1000),
+ description TEXT,
+ main_image_url TEXT,
+ image_urls JSONB DEFAULT '[]',
+ video_urls JSONB DEFAULT '[]',
+
+ -- 价格信息
+ base_price DECIMAL(12,2) NOT NULL CHECK (base_price >= 0),
+ market_price DECIMAL(12,2),
+ cost_price DECIMAL(12,2),
+
+ -- 库存信息
+ total_stock INTEGER DEFAULT 0 CHECK (total_stock >= 0),
+ available_stock INTEGER DEFAULT 0 CHECK (available_stock >= 0),
+ min_order_qty INTEGER DEFAULT 1 CHECK (min_order_qty > 0),
+ max_order_qty INTEGER,
+
+ -- 基础属性
+ weight DECIMAL(10,3),
+ dimensions JSONB, -- {length, width, height}
+
+ -- 状态
+ status INTEGER DEFAULT 1, -- 1:上架 2:下架 3:草稿 4:删除
+ is_featured BOOLEAN DEFAULT FALSE,
+ is_new BOOLEAN DEFAULT FALSE,
+ is_hot BOOLEAN DEFAULT FALSE,
+
+ -- 统计
+ view_count INTEGER DEFAULT 0,
+ sale_count INTEGER DEFAULT 0,
+ favorite_count INTEGER DEFAULT 0,
+ rating_avg DECIMAL(3,2) DEFAULT 0.00 CHECK (rating_avg >= 0 AND rating_avg <= 5),
+ rating_count INTEGER DEFAULT 0,
+
+ -- SEO
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ seo_keywords TEXT[],
+ slug VARCHAR(200) UNIQUE,
+
+ -- 其他
+ tags TEXT[],
+ attributes JSONB DEFAULT '{}',
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ published_at TIMESTAMP WITH TIME ZONE,
+
+ CONSTRAINT chk_ml_product_status CHECK (status IN (1,2,3,4))
+);
+
+COMMENT ON TABLE public.ml_products IS '商品表;
+
+-- 商品SKU表
+CREATE TABLE public.ml_product_skus (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_code VARCHAR(100) UNIQUE NOT NULL,
+ specifications JSONB DEFAULT '{}', -- 规格组合
+ price DECIMAL(12,2) NOT NULL CHECK (price >= 0),
+ market_price DECIMAL(12,2),
+ cost_price DECIMAL(12,2),
+ stock INTEGER DEFAULT 0 CHECK (stock >= 0),
+ warning_stock INTEGER DEFAULT 10, -- 库存预警
+ image_url TEXT,
+ weight DECIMAL(10,3),
+ status INTEGER DEFAULT 1, -- 1:正常 2:禁用
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_sku_status CHECK (status IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_product_skus IS '商品SKU表;
+
+-- 商品规格表
+CREATE TABLE public.ml_product_specs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ spec_name VARCHAR(100) NOT NULL, -- 规格名称:颜色、尺寸等
+ spec_values JSONB NOT NULL DEFAULT '[]', -- 规格值数组
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_product_specs IS '商品规格表;
+
+-- =====================================================================================
+-- 4. 店铺管理模块
+-- =====================================================================================
+
+-- 店铺信息表
+CREATE TABLE public.ml_shops (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ shop_name VARCHAR(200) NOT NULL,
+ shop_logo TEXT,
+ shop_banner TEXT,
+ description TEXT,
+ business_license VARCHAR(100),
+ contact_name VARCHAR(100),
+ contact_phone VARCHAR(32),
+ contact_email VARCHAR(200),
+ address JSONB, -- 店铺地址信息
+ business_hours JSONB, -- 营业时间
+
+ -- 状态
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:关闭
+
+ -- 统计
+ product_count INTEGER DEFAULT 0,
+ order_count INTEGER DEFAULT 0,
+ rating_avg DECIMAL(3,2) DEFAULT 0.00,
+ rating_count INTEGER DEFAULT 0,
+
+ -- 认证信息
+ verified_at TIMESTAMP WITH TIME ZONE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_shop_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_shops IS '店铺信息表;
+
+-- =====================================================================================
+-- 5. 订单管理模块
+-- =====================================================================================
+
+-- 订单表
+CREATE TABLE public.ml_orders (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ order_no VARCHAR(50) UNIQUE NOT NULL,
+ user_id UUID NOT NULL REFERENCES public.ak_users(id),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+
+ -- 金额信息
+ product_amount DECIMAL(12,2) NOT NULL DEFAULT 0, -- 商品金额
+ discount_amount DECIMAL(12,2) DEFAULT 0, -- 优惠金额
+ shipping_fee DECIMAL(12,2) DEFAULT 0, -- 运费
+ total_amount DECIMAL(12,2) NOT NULL, -- 总金额
+ paid_amount DECIMAL(12,2) DEFAULT 0, -- 已付金额
+
+ -- 地址信息
+ shipping_address JSONB NOT NULL, -- 收货地址
+
+ -- 状态信息
+ order_status INTEGER DEFAULT 1, -- 1:待付款2:待发货3:待收货4:已完成5:已取消6:退款中 7:已退款
+ payment_status INTEGER DEFAULT 1, -- 1:未付款2:已付款3:部分退款4:全额退款
+ shipping_status INTEGER DEFAULT 1, -- 1:未发货2:已发货3:运输中4:已送达
+
+ -- 时间信息
+ paid_at TIMESTAMP WITH TIME ZONE,
+ shipped_at TIMESTAMP WITH TIME ZONE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- 其他信息
+ remark TEXT, -- 买家备注
+ merchant_memo TEXT, -- 商家备注
+ cancel_reason TEXT, -- 取消原因
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_order_status CHECK (order_status IN (1,2,3,4,5,6,7)),
+ CONSTRAINT chk_ml_payment_status CHECK (payment_status IN (1,2,3,4)),
+ CONSTRAINT chk_ml_shipping_status CHECK (shipping_status IN (1,2,3,4))
+);
+
+COMMENT ON TABLE public.ml_orders IS '订单表;
+
+-- 订单商品表
+CREATE TABLE public.ml_order_items (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID NOT NULL REFERENCES public.ml_orders(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id),
+ sku_id UUID REFERENCES public.ml_product_skus(id),
+ product_name VARCHAR(500) NOT NULL,
+ sku_name VARCHAR(500),
+ specifications JSONB DEFAULT '{}',
+ image_url TEXT,
+ price DECIMAL(12,2) NOT NULL,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ total_amount DECIMAL(12,2) NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_order_items IS '订单商品表;
+
+-- =====================================================================================
+-- 6. 购物车表
+-- =====================================================================================
+
+-- 购物车表
+CREATE TABLE public.ml_shopping_cart (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ sku_id UUID REFERENCES public.ml_product_skus(id) ON DELETE CASCADE,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ selected BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, product_id, sku_id)
+);
+
+COMMENT ON TABLE public.ml_shopping_cart IS '购物车表';
+
+-- =====================================================================================
+-- 7. 营销管理模块
+-- =====================================================================================
+
+-- 优惠券模板表
+CREATE TABLE public.ml_coupon_templates (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ cid SERIAL UNIQUE NOT NULL, -- SEO友好的自增ID
+ merchant_id UUID REFERENCES public.ak_users(id), -- NULL表示平台券
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ coupon_type INTEGER NOT NULL, -- 1:满减券2:折扣券3:免运费券
+ discount_type INTEGER NOT NULL, -- 1:固定金额 2:百分比
+ discount_value DECIMAL(12,2) NOT NULL, -- 优惠值
+ min_order_amount DECIMAL(12,2) DEFAULT 0, -- 最低订单金额
+ max_discount_amount DECIMAL(12,2), -- 最大优惠金额
+ total_quantity INTEGER, -- 总发放数量
+ per_user_limit INTEGER DEFAULT 1, -- 每用户限领数量
+ usage_limit INTEGER DEFAULT 1, -- 每张券使用次数限制
+
+ -- 适用范围
+ applicable_products JSONB DEFAULT '[]', -- 适用商品ID数组
+ applicable_categories JSONB DEFAULT '[]', -- 适用分类ID数组
+
+ -- 时间限制
+ start_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_time TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:已结束
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_coupon_type CHECK (coupon_type IN (1,2,3)),
+ CONSTRAINT chk_ml_discount_type CHECK (discount_type IN (1,2)),
+ CONSTRAINT chk_ml_coupon_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_coupon_templates IS '优惠券模板表';
+
+-- 用户优惠券表
+CREATE TABLE public.ml_user_coupons (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ template_id UUID NOT NULL REFERENCES public.ml_coupon_templates(id),
+ coupon_code VARCHAR(50) UNIQUE NOT NULL,
+ status INTEGER DEFAULT 1, -- 1:未使用2:已使用3:已过期
+ used_at TIMESTAMP WITH TIME ZONE,
+ order_id UUID REFERENCES public.ml_orders(id),
+ received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ expire_at TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ CONSTRAINT chk_ml_user_coupon_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_user_coupons IS '用户优惠券表';
+
+-- =====================================================================================
+-- 8. 配送管理表
+-- =====================================================================================
+
+-- 配送员信息表
+CREATE TABLE public.ml_delivery_drivers (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID UNIQUE NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ real_name VARCHAR(100) NOT NULL,
+ id_card VARCHAR(32) NOT NULL,
+ driver_license VARCHAR(50),
+ vehicle_type INTEGER, -- 1:电动车2:摩托车3:汽车
+ vehicle_number VARCHAR(20),
+ service_areas JSONB DEFAULT '[]', -- 服务区域
+ work_status INTEGER DEFAULT 1, -- 1:在线 2:忙碌 3:离线
+ current_lat DECIMAL(10,7),
+ current_lng DECIMAL(10,7),
+ rating_avg DECIMAL(3,2) DEFAULT 0.00,
+ rating_count INTEGER DEFAULT 0,
+ order_count INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1, -- 1:正常 2:暂停 3:离职
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_driver_vehicle_type CHECK (vehicle_type IN (1,2,3)),
+ CONSTRAINT chk_ml_driver_work_status CHECK (work_status IN (1,2,3)),
+ CONSTRAINT chk_ml_driver_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_delivery_drivers IS '配送员信息表;
+
+-- 配送任务表
+CREATE TABLE public.ml_delivery_tasks (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID UNIQUE NOT NULL REFERENCES public.ml_orders(id),
+ driver_id UUID REFERENCES public.ml_delivery_drivers(id),
+ pickup_address JSONB NOT NULL, -- 取货地址
+ delivery_address JSONB NOT NULL, -- 配送地址
+ distance DECIMAL(8,2), -- 配送距离km)
+ estimated_time INTEGER, -- 预计配送时间分钟)
+ delivery_fee DECIMAL(10,2) NOT NULL DEFAULT 0,
+
+ status INTEGER DEFAULT 1, -- 1:待接单2:已接单3:取货中4:配送中 5:已送达 6:配送失败
+
+ -- 时间记录
+ assigned_at TIMESTAMP WITH TIME ZONE,
+ picked_at TIMESTAMP WITH TIME ZONE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+
+ -- 其他信息
+ delivery_code VARCHAR(10), -- 取货中
+ remark TEXT,
+ failure_reason TEXT,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_delivery_status CHECK (status IN (1,2,3,4,5,6))
+);
+
+COMMENT ON TABLE public.ml_delivery_tasks IS '配送任务表';
+
+-- =====================================================================================
+-- 9. 评价管理模块
+-- =====================================================================================
+
+-- 商品评价表
+CREATE TABLE public.ml_product_reviews (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ order_id UUID NOT NULL REFERENCES public.ml_orders(id),
+ order_item_id UUID NOT NULL REFERENCES public.ml_order_items(id),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id),
+ product_id UUID NOT NULL REFERENCES public.ml_products(id),
+ merchant_id UUID NOT NULL REFERENCES public.ak_users(id),
+
+ rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ content TEXT,
+ images JSONB DEFAULT '[]', -- 评价图片
+ is_anonymous BOOLEAN DEFAULT FALSE,
+
+ -- 商家回复
+ merchant_reply TEXT,
+ merchant_replied_at TIMESTAMP WITH TIME ZONE,
+
+ status INTEGER DEFAULT 1, -- 1:正常 2:已删除3:已隐藏
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ CONSTRAINT chk_ml_review_status CHECK (status IN (1,2,3))
+);
+
+COMMENT ON TABLE public.ml_product_reviews IS '商品评价表;
+
+-- =====================================================================================
+-- 10. 用户行为模块
+-- =====================================================================================
+
+-- 用户收藏表
+CREATE TABLE public.ml_user_favorites (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ target_type INTEGER NOT NULL, -- 1:商品 2:店铺
+ target_id UUID NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, target_type, target_id),
+ CONSTRAINT chk_ml_favorite_type CHECK (target_type IN (1,2))
+);
+
+COMMENT ON TABLE public.ml_user_favorites IS '用户收藏表;
+
+-- 用户浏览历史表
+CREATE TABLE public.ml_browse_history (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
+ browse_duration INTEGER DEFAULT 0, -- 浏览时长(秒)
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_id, product_id)
+);
+
+COMMENT ON TABLE public.ml_browse_history IS '用户浏览历史表;
+
+-- 搜索记录表
+CREATE TABLE public.ml_search_history (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID REFERENCES public.ak_users(id) ON DELETE CASCADE,
+ keyword VARCHAR(200) NOT NULL,
+ result_count INTEGER DEFAULT 0,
+ ip_address INET,
+ user_agent TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_search_history IS '搜索记录表;
+
+-- =====================================================================================
+-- 11. 系统配置表
+-- =====================================================================================
+
+-- 系统配置表
+CREATE TABLE public.ml_system_configs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ config_key VARCHAR(100) UNIQUE NOT NULL,
+ config_value JSONB,
+ description TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_system_configs IS '系统配置表;
+
+-- 地区表如果需要独立的地区管理)
+CREATE TABLE public.ml_regions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ parent_id UUID REFERENCES public.ml_regions(id),
+ name VARCHAR(100) NOT NULL,
+ code VARCHAR(20),
+ level INTEGER NOT NULL, -- 1:省份 2:城市 3:区县 4:街道
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+COMMENT ON TABLE public.ml_regions IS '地区表;
+
+-- =====================================================================================
+-- 12. 创建索引
+-- =====================================================================================
+
+-- 用户扩展表索引
+CREATE INDEX idx_ml_user_profiles_user_id ON public.ml_user_profiles(user_id);
+
+CREATE INDEX idx_ml_user_profiles_status ON public.ml_user_profiles(status);
+
+-- 分类表索引
+CREATE INDEX idx_ml_categories_cid ON public.ml_categories(cid);
+CREATE INDEX idx_ml_categories_parent ON public.ml_categories(parent_id);
+CREATE INDEX idx_ml_categories_slug ON public.ml_categories(slug);
+CREATE INDEX idx_ml_categories_level ON public.ml_categories(level, sort_order);
+
+-- 品牌表索引
+CREATE INDEX idx_ml_brands_cid ON public.ml_brands(cid);
+CREATE INDEX idx_ml_brands_name ON public.ml_brands(name);
+
+-- 地址表索引
+CREATE INDEX idx_ml_user_addresses_user_id ON public.ml_user_addresses(user_id);
+CREATE INDEX idx_ml_user_addresses_default ON public.ml_user_addresses(user_id, is_default);
+CREATE INDEX idx_ml_user_addresses_location ON public.ml_user_addresses(city, district);
+
+-- 商品表索引
+CREATE INDEX idx_ml_products_cid ON public.ml_products(cid);
+CREATE INDEX idx_ml_products_merchant ON public.ml_products(merchant_id, status);
+CREATE INDEX idx_ml_products_category ON public.ml_products(category_id, status);
+CREATE INDEX idx_ml_products_status ON public.ml_products(status, created_at DESC);
+CREATE INDEX idx_ml_products_featured ON public.ml_products(is_featured, status);
+CREATE INDEX idx_ml_products_price ON public.ml_products(base_price);
+CREATE INDEX idx_ml_products_rating ON public.ml_products(rating_avg DESC, rating_count DESC);
+CREATE INDEX idx_ml_products_sale_count ON public.ml_products(sale_count DESC);
+CREATE INDEX idx_ml_products_tags ON public.ml_products USING GIN(tags);
+CREATE INDEX idx_ml_products_slug ON public.ml_products(slug);
+
+-- 店铺表索引
+CREATE INDEX idx_ml_shops_cid ON public.ml_shops(cid);
+CREATE INDEX idx_ml_shops_merchant ON public.ml_shops(merchant_id);
+
+-- SKU表索引
+CREATE INDEX idx_ml_product_skus_product ON public.ml_product_skus(product_id);
+CREATE INDEX idx_ml_product_skus_code ON public.ml_product_skus(sku_code);
+
+-- 订单表索引
+CREATE INDEX idx_ml_orders_cid ON public.ml_orders(cid);
+CREATE INDEX idx_ml_orders_user ON public.ml_orders(user_id, created_at DESC);
+CREATE INDEX idx_ml_orders_merchant ON public.ml_orders(merchant_id, created_at DESC);
+CREATE INDEX idx_ml_orders_status ON public.ml_orders(order_status, created_at DESC);
+CREATE INDEX idx_ml_orders_no ON public.ml_orders(order_no);
+
+-- 订单商品表索引
+CREATE INDEX idx_ml_order_items_order ON public.ml_order_items(order_id);
+CREATE INDEX idx_ml_order_items_product ON public.ml_order_items(product_id);
+
+-- 购物车表索引
+CREATE INDEX idx_ml_shopping_cart_user ON public.ml_shopping_cart(user_id);
+
+-- 优惠券模板表索引
+CREATE INDEX idx_ml_coupon_templates_cid ON public.ml_coupon_templates(cid);
+CREATE INDEX idx_ml_coupon_templates_merchant ON public.ml_coupon_templates(merchant_id);
+
+-- 优惠券表索引
+CREATE INDEX idx_ml_user_coupons_user ON public.ml_user_coupons(user_id, status);
+CREATE INDEX idx_ml_user_coupons_code ON public.ml_user_coupons(coupon_code);
+
+-- 收藏表索引
+CREATE INDEX idx_ml_user_favorites_user ON public.ml_user_favorites(user_id, target_type);
+CREATE INDEX idx_ml_user_favorites_target ON public.ml_user_favorites(target_type, target_id);
+
+-- 浏览历史索引
+CREATE INDEX idx_ml_browse_history_user ON public.ml_browse_history(user_id, created_at DESC);
+CREATE INDEX idx_ml_browse_history_product ON public.ml_browse_history(product_id);
+
+-- =====================================================================================
+-- 13. 触发器函数
+-- =====================================================================================
+
+-- 自动更新 updated_at 字段的函数
+CREATE OR REPLACE FUNCTION public.update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 为需要的表创建 updated_at 触发器
+CREATE TRIGGER trigger_ml_user_profiles_updated_at
+ BEFORE UPDATE ON public.ml_user_profiles
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_user_addresses_updated_at
+ BEFORE UPDATE ON public.ml_user_addresses
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_products_updated_at
+ BEFORE UPDATE ON public.ml_products
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_product_skus_updated_at
+ BEFORE UPDATE ON public.ml_product_skus
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_shops_updated_at
+ BEFORE UPDATE ON public.ml_shops
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_orders_updated_at
+ BEFORE UPDATE ON public.ml_orders
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER trigger_ml_shopping_cart_updated_at
+ BEFORE UPDATE ON public.ml_shopping_cart
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- 确保每个用户只有一个默认地址的触发器
+CREATE OR REPLACE FUNCTION public.ensure_single_default_address()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.is_default = TRUE THEN
+ UPDATE public.ml_user_addresses
+ SET is_default = FALSE
+ WHERE user_id = NEW.user_id AND id != NEW.id;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_single_default_address
+ BEFORE INSERT OR UPDATE ON public.ml_user_addresses
+ FOR EACH ROW EXECUTE FUNCTION public.ensure_single_default_address();
+
+-- 商品库存更新触发器
+CREATE OR REPLACE FUNCTION public.update_product_stock()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- 更新商品总库存
+ UPDATE public.ml_products
+ SET
+ total_stock = (
+ SELECT COALESCE(SUM(stock), 0)
+ FROM public.ml_product_skus
+ WHERE product_id = NEW.product_id AND status = 1
+ ),
+ available_stock = (
+ SELECT COALESCE(SUM(stock), 0)
+ FROM public.ml_product_skus
+ WHERE product_id = NEW.product_id AND status = 1
+ )
+ WHERE id = NEW.product_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_update_product_stock
+ AFTER INSERT OR UPDATE OR DELETE ON public.ml_product_skus
+ FOR EACH ROW EXECUTE FUNCTION public.update_product_stock();
+
+-- 订单状态变更时的处理
+CREATE OR REPLACE FUNCTION public.handle_order_status_change()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- 如果订单状态变为已付款
+ IF NEW.order_status = 2 AND OLD.order_status = 1 THEN
+ NEW.paid_at = NOW();
+ END IF;
+
+ -- 如果订单状态变为已发货
+ IF NEW.order_status = 3 AND OLD.order_status = 2 THEN
+ NEW.shipped_at = NOW();
+ END IF;
+
+ -- 如果订单状态变为已完成
+ IF NEW.order_status = 4 AND OLD.order_status = 3 THEN
+ NEW.delivered_at = NOW();
+ NEW.completed_at = NOW();
+
+ -- 更新商品销量
+ UPDATE public.ml_products
+ SET sale_count = sale_count + (
+ SELECT SUM(quantity)
+ FROM public.ml_order_items
+ WHERE order_id = NEW.id
+ )
+ WHERE id IN (
+ SELECT product_id
+ FROM public.ml_order_items
+ WHERE order_id = NEW.id
+ );
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_ml_order_status_change
+ BEFORE UPDATE ON public.ml_orders
+ FOR EACH ROW EXECUTE FUNCTION public.handle_order_status_change();
+
+-- =====================================================================================
+-- 14. 实用函数
+-- =====================================================================================
+
+-- 生成订单号的函数
+CREATE OR REPLACE FUNCTION public.generate_order_no()
+RETURNS TEXT AS $$
+DECLARE
+ order_no TEXT;
+BEGIN
+ order_no := 'ML' || TO_CHAR(NOW(), 'YYYYMMDD') || LPAD(NEXTVAL('ml_order_seq')::TEXT, 6, '0');
+ RETURN order_no;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 创建订单序列
+CREATE SEQUENCE IF NOT EXISTS public.ml_order_seq START 1;
+
+-- 生成优惠券码的函数
+CREATE OR REPLACE FUNCTION public.generate_coupon_code()
+RETURNS TEXT AS $$
+DECLARE
+ code TEXT;
+ chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ result TEXT := '';
+ i INTEGER;
+BEGIN
+ FOR i IN 1..8 LOOP
+ result := result || substr(chars, (random() * length(chars))::integer + 1, 1);
+ END LOOP;
+ RETURN 'CP' || result;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 获取用户默认地址
+CREATE OR REPLACE FUNCTION public.get_user_default_address(p_user_id UUID)
+RETURNS TABLE (
+ id UUID,
+ receiver_name VARCHAR,
+ receiver_phone VARCHAR,
+ full_address TEXT,
+ latitude DECIMAL,
+ longitude DECIMAL
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ a.id,
+ a.receiver_name,
+ a.receiver_phone,
+ (a.province || ' ' || a.city || ' ' || a.district || ' ' || a.address_detail) as full_address,
+ a.latitude,
+ a.longitude
+ FROM public.ml_user_addresses a
+ WHERE a.user_id = p_user_id AND a.is_default = TRUE AND a.status = 1
+ LIMIT 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 检查用户是否为认证商家
+CREATE OR REPLACE FUNCTION public.is_verified_merchant(p_user_id UUID)
+RETURNS BOOLEAN AS $$
+DECLARE
+ result BOOLEAN := FALSE;
+BEGIN
+ SELECT (u.role = 'merchant' AND p.verification_status = 1) INTO result
+ FROM public.ml_user_profiles p
+ JOIN public.ak_users u ON p.user_id = u.id
+ WHERE p.user_id = p_user_id;
+
+ RETURN COALESCE(result, FALSE);
+END;
+$$ LANGUAGE plpgsql;
+
+-- 计算购物车总金额
+CREATE OR REPLACE FUNCTION public.calculate_cart_total(p_user_id UUID)
+RETURNS DECIMAL AS $$
+DECLARE
+ total_amount DECIMAL := 0;
+BEGIN
+ SELECT COALESCE(SUM(s.price * c.quantity), 0) INTO total_amount
+ FROM public.ml_shopping_cart c
+ LEFT JOIN public.ml_product_skus s ON c.sku_id = s.id
+ LEFT JOIN public.ml_products p ON c.product_id = p.id
+ WHERE c.user_id = p_user_id
+ AND c.selected = TRUE
+ AND p.status = 1
+ AND (s.id IS NULL OR s.status = 1);
+
+ RETURN total_amount;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 获取商品可用库存
+CREATE OR REPLACE FUNCTION public.get_product_available_stock(p_product_id UUID, p_sku_id UUID DEFAULT NULL)
+RETURNS INTEGER AS $$
+DECLARE
+ stock_count INTEGER := 0;
+BEGIN
+ IF p_sku_id IS NOT NULL THEN
+ -- 获取特定SKU库存
+ SELECT COALESCE(stock, 0) INTO stock_count
+ FROM public.ml_product_skus
+ WHERE id = p_sku_id AND product_id = p_product_id AND status = 1;
+ ELSE
+ -- 获取商品总库存
+ SELECT COALESCE(available_stock, 0) INTO stock_count
+ FROM public.ml_products
+ WHERE id = p_product_id AND status = 1;
+ END IF;
+
+ RETURN stock_count;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =====================================================================================
+-- 15. 创建视图
+-- =====================================================================================
+
+-- 商城用户完整信息视图
+CREATE OR REPLACE VIEW public.ml_users_view AS
+SELECT
+ u.id,
+ u.username,
+ u.email,
+ u.phone,
+ u.avatar_url,
+ u.gender,
+ u.birthday,
+ u.bio,
+ u.created_at as user_created_at,
+ u.updated_at as user_updated_at,
+ u.role,
+ p.status,
+ p.real_name,
+ p.credit_score,
+ p.verification_status,
+ p.created_at as profile_created_at,
+ p.updated_at as profile_updated_at,
+ CASE
+ WHEN u.role = 'customer' THEN '消费者'
+ WHEN u.role = 'merchant' THEN '商家'
+ WHEN u.role = 'delivery' THEN '配送员'
+ WHEN u.role = 'service' THEN '客服'
+ WHEN u.role = 'admin' THEN '管理员'
+ ELSE '未知'
+ END as role_name
+FROM public.ak_users u
+LEFT JOIN public.ml_user_profiles p ON u.id = p.user_id;
+
+COMMENT ON VIEW public.ml_users_view IS '商城用户完整信息视图';
+
+-- 商品详情视图
+CREATE OR REPLACE VIEW public.ml_products_detail_view AS
+SELECT
+ p.*,
+ c.cid as category_cid,
+ c.name as category_name,
+ c.path as category_path,
+ b.cid as brand_cid,
+ b.name as brand_name,
+ s.cid as shop_cid,
+ s.shop_name,
+ u.username as merchant_name,
+ CASE
+ WHEN p.status = 1 THEN '上架'
+ WHEN p.status = 2 THEN '下架'
+ WHEN p.status = 3 THEN '草稿'
+ WHEN p.status = 4 THEN '删除'
+ ELSE '未知'
+ END as status_name
+FROM public.ml_products p
+LEFT JOIN public.ml_categories c ON p.category_id = c.id
+LEFT JOIN public.ml_brands b ON p.brand_id = b.id
+LEFT JOIN public.ml_shops s ON p.merchant_id = s.merchant_id
+LEFT JOIN public.ak_users u ON p.merchant_id = u.id;
+
+COMMENT ON VIEW public.ml_products_detail_view IS '商品详情视图';
+
+-- 订单详情视图
+CREATE OR REPLACE VIEW public.ml_orders_detail_view AS
+SELECT
+ o.*,
+ u.username as customer_name,
+ u.phone as customer_phone,
+ m.username as merchant_name,
+ s.shop_name,
+ CASE
+ WHEN o.order_status = 1 THEN '待付款
+ WHEN o.order_status = 2 THEN '待发货
+ WHEN o.order_status = 3 THEN '待收货
+ WHEN o.order_status = 4 THEN '已完成
+ WHEN o.order_status = 5 THEN '已取消
+ WHEN o.order_status = 6 THEN '退款中'
+ WHEN o.order_status = 7 THEN '已退款
+ ELSE '未知'
+ END as order_status_name,
+ CASE
+ WHEN o.payment_status = 1 THEN '未付款
+ WHEN o.payment_status = 2 THEN '已付款
+ WHEN o.payment_status = 3 THEN '部分退款
+ WHEN o.payment_status = 4 THEN '全额退款
+ ELSE '未知'
+ END as payment_status_name
+FROM public.ml_orders o
+LEFT JOIN public.ak_users u ON o.user_id = u.id
+LEFT JOIN public.ak_users m ON o.merchant_id = m.id
+LEFT JOIN public.ml_shops s ON o.merchant_id = s.merchant_id;
+
+COMMENT ON VIEW public.ml_orders_detail_view IS '订单详情视图';
+
+-- =====================================================================================
+-- 16. RLS (Row Level Security) 策略
+-- =====================================================================================
+
+-- 启用 RLS
+ALTER TABLE public.ml_user_profiles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_addresses ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_shopping_cart ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_favorites ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_browse_history ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_user_coupons ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_orders ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ml_products ENABLE ROW LEVEL SECURITY;
+
+-- 用户只能访问自己的数据
+CREATE POLICY ml_user_profiles_select_policy ON public.ml_user_profiles
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_insert_policy ON public.ml_user_profiles
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_update_policy ON public.ml_user_profiles
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_profiles_delete_policy ON public.ml_user_profiles
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_select_policy ON public.ml_user_addresses
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_insert_policy ON public.ml_user_addresses
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_update_policy ON public.ml_user_addresses
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_addresses_delete_policy ON public.ml_user_addresses
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_select_policy ON public.ml_shopping_cart
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_insert_policy ON public.ml_shopping_cart
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_update_policy ON public.ml_shopping_cart
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_shopping_cart_delete_policy ON public.ml_shopping_cart
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_select_policy ON public.ml_user_favorites
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_insert_policy ON public.ml_user_favorites
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_update_policy ON public.ml_user_favorites
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_favorites_delete_policy ON public.ml_user_favorites
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_select_policy ON public.ml_browse_history
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_insert_policy ON public.ml_browse_history
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_update_policy ON public.ml_browse_history
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_browse_history_delete_policy ON public.ml_browse_history
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_select_policy ON public.ml_user_coupons
+ FOR SELECT USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_insert_policy ON public.ml_user_coupons
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_update_policy ON public.ml_user_coupons
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+CREATE POLICY ml_user_coupons_delete_policy ON public.ml_user_coupons
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = user_id)
+ );
+
+-- 订单策略:用户可以查看自己的订单,商家可以查看自己店铺的订单
+CREATE POLICY ml_orders_select_policy ON public.ml_orders
+ FOR SELECT USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_insert_policy ON public.ml_orders
+ FOR INSERT WITH CHECK (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_update_policy ON public.ml_orders
+ FOR UPDATE USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+CREATE POLICY ml_orders_delete_policy ON public.ml_orders
+ FOR DELETE USING (
+ auth.uid() IN (
+ SELECT auth_id FROM public.ak_users WHERE id IN (user_id, merchant_id)
+ )
+ );
+
+-- 商品策略:所有人可以查看上架商品,商家只能管理自己的商品
+CREATE POLICY ml_products_select_policy ON public.ml_products
+ FOR SELECT USING (status = 1);
+
+CREATE POLICY ml_products_insert_policy ON public.ml_products
+ FOR INSERT WITH CHECK (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+CREATE POLICY ml_products_update_policy ON public.ml_products
+ FOR UPDATE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+CREATE POLICY ml_products_delete_policy ON public.ml_products
+ FOR DELETE USING (
+ auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = merchant_id)
+ );
+
+-- =====================================================================================
+-- 17. 初始化数据
+-- =====================================================================================
+
+-- 插入系统配置
+INSERT INTO public.ml_system_configs (config_key, config_value, description) VALUES
+('shipping_fee', '{"default": 10, "free_threshold": 88}', '配送费配置'),
+('platform_commission', '{"rate": 0.05}', '平台佣金配置'),
+('coupon_settings', '{"max_per_user": 10}', '优惠券设置),
+('order_auto_confirm_days', '7', '订单自动确认天数');
+
+-- 插入默认分类
+INSERT INTO public.ml_categories (id, name, slug, level, path) VALUES
+(uuid_generate_v4(), '数码电器', 'digital', 1, ARRAY['数码电器']),
+(uuid_generate_v4(), '服装鞋帽', 'fashion', 1, ARRAY['服装鞋帽']),
+(uuid_generate_v4(), '家居用品', 'home', 1, ARRAY['家居用品']),
+(uuid_generate_v4(), '食品饮料', 'food', 1, ARRAY['食品饮料']),
+(uuid_generate_v4(), '美妆护肤', 'beauty', 1, ARRAY['美妆护肤']);
+
+-- 为现有 ak_users 用户创建默认商城档案
+INSERT INTO public.ml_user_profiles (user_id, status)
+SELECT
+ id,
+ 1 -- 默认状态正常
+FROM public.ak_users
+WHERE id NOT IN (SELECT user_id FROM public.ml_user_profiles WHERE user_id IS NOT NULL);
+
+-- =====================================================================================
+-- 18. 完成提示
+-- =====================================================================================
+
+DO $$
+BEGIN
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '商城数据库创建完成!';
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '已创建表数量: 20+ 张表';
+ RAISE NOTICE '已创建索引 30+ 个索引;
+ RAISE NOTICE '已创建触发器: 8 个触发器';
+ RAISE NOTICE '已创建函数 10+ 个函数;
+ RAISE NOTICE '已创建视图 3 个视图;
+ RAISE NOTICE '已设置RLS策略: 多个策略';
+ RAISE NOTICE '已为现有用户创建默认档案';
+ RAISE NOTICE '=======================================================';
+ RAISE NOTICE '表名前缀: ml_';
+ RAISE NOTICE '复用主表 ak_users';
+ RAISE NOTICE '兼容: Supabase';
+ RAISE NOTICE '=======================================================';
+END $$;
+
+-- =====================================================================================
+-- SEO 优化相关函数
+-- =====================================================================================
+
+-- 根据 cid 获取商品信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_product_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ slug VARCHAR,
+ description TEXT,
+ main_image_url TEXT,
+ base_price DECIMAL,
+ rating_avg DECIMAL,
+ sale_count INTEGER,
+ category_name VARCHAR,
+ brand_name VARCHAR,
+ shop_name VARCHAR
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ p.id,
+ p.cid,
+ p.name,
+ p.slug,
+ p.description,
+ p.main_image_url,
+ p.base_price,
+ p.rating_avg,
+ p.sale_count,
+ c.name as category_name,
+ b.name as brand_name,
+ s.shop_name
+ FROM public.ml_products p
+ LEFT JOIN public.ml_categories c ON p.category_id = c.id
+ LEFT JOIN public.ml_brands b ON p.brand_id = b.id
+ LEFT JOIN public.ml_shops s ON p.merchant_id = s.merchant_id
+ WHERE p.cid = p_cid AND p.status = 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取分类信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_category_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ slug VARCHAR,
+ description TEXT,
+ icon_url TEXT,
+ path TEXT[]
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ c.id,
+ c.cid,
+ c.name,
+ c.slug,
+ c.description,
+ c.icon_url,
+ c.path
+ FROM public.ml_categories c
+ WHERE c.cid = p_cid AND c.is_active = TRUE;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取品牌信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_brand_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ name VARCHAR,
+ logo_url TEXT,
+ description TEXT
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ b.id,
+ b.cid,
+ b.name,
+ b.logo_url,
+ b.description
+ FROM public.ml_brands b
+ WHERE b.cid = p_cid AND b.is_active = TRUE;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 根据 cid 获取店铺信息 (SEO 友好)
+CREATE OR REPLACE FUNCTION public.get_shop_by_cid(p_cid INTEGER)
+RETURNS TABLE (
+ id UUID,
+ cid INTEGER,
+ shop_name VARCHAR,
+ description TEXT,
+ shop_logo TEXT,
+ rating_avg DECIMAL,
+ product_count INTEGER
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ s.id,
+ s.cid,
+ s.shop_name,
+ s.description,
+ s.shop_logo,
+ s.rating_avg,
+ s.product_count
+ FROM public.ml_shops s
+ WHERE s.cid = p_cid AND s.status = 1;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 生成 SEO 友好的 URL 路径
+CREATE OR REPLACE FUNCTION public.generate_seo_url(
+ p_type VARCHAR, -- 'product', 'category', 'brand', 'shop'
+ p_cid INTEGER,
+ p_slug VARCHAR DEFAULT NULL
+)
+RETURNS TEXT AS $$
+DECLARE
+ url_path TEXT;
+BEGIN
+ CASE p_type
+ WHEN 'product' THEN
+ url_path := '/product/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'category' THEN
+ url_path := '/category/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'brand' THEN
+ url_path := '/brand/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ WHEN 'shop' THEN
+ url_path := '/shop/' || p_cid;
+ IF p_slug IS NOT NULL THEN
+ url_path := url_path || '/' || p_slug;
+ END IF;
+ ELSE
+ url_path := '/' || p_type || '/' || p_cid;
+ END CASE;
+
+ RETURN url_path;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 批量更新 slug 字段(用于现有数据)
+CREATE OR REPLACE FUNCTION public.update_seo_slugs()
+RETURNS VOID AS $$
+BEGIN
+ -- 更新商品 slug
+ UPDATE public.ml_products
+ SET slug = LOWER(REGEXP_REPLACE(name, '[^a-zA-Z0-9\u4e00-\u9fa5]+', '-', 'g'))
+ WHERE slug IS NULL OR slug = '';
+
+ -- 更新分类 slug
+ UPDATE public.ml_categories
+ SET slug = LOWER(REGEXP_REPLACE(name, '[^a-zA-Z0-9\u4e00-\u9fa5]+', '-', 'g'))
+ WHERE slug IS NULL OR slug = '';
+
+ RAISE NOTICE 'SEO slugs updated successfully';
+END;
+$$ LANGUAGE plpgsql;
diff --git a/mall_sql/tests/dev_home_service_package_pricing_cleanup.sql b/mall_sql/tests/dev_home_service_package_pricing_cleanup.sql
new file mode 100644
index 00000000..925fedc9
--- /dev/null
+++ b/mall_sql/tests/dev_home_service_package_pricing_cleanup.sql
@@ -0,0 +1,30 @@
+-- ================================================================
+-- DEV / TEST CLEANUP ONLY
+-- 仅停用 DEV_HOME_SERVICE_PACKAGE_V1 批次的居家服务测试套餐。
+-- 如已有测试订单引用这些套餐,请保留订单金额快照,不要直接物理删除。
+-- 推荐执行时机:正式套餐导入完成且生产联调验证通过后。
+-- ================================================================
+
+BEGIN;
+
+-- 执行前建议先人工确认以下记录:
+-- SELECT id, service_id, package_name, status, data_source, seed_batch_no, deleted_at
+-- FROM public.hss_service_packages
+-- WHERE data_source = 'dev_seed'
+-- AND seed_batch_no = 'DEV_HOME_SERVICE_PACKAGE_V1'
+-- ORDER BY service_id, sort_no;
+
+UPDATE public.hss_service_packages
+SET
+ status = 0,
+ deleted_at = COALESCE(deleted_at, now()),
+ updated_at = now(),
+ remark = CASE
+ WHEN remark = '' THEN 'DEV_HOME_SERVICE_PACKAGE_V1 已执行 cleanup 停用。'
+ WHEN remark LIKE '%cleanup 停用%' THEN remark
+ ELSE remark || ' DEV_HOME_SERVICE_PACKAGE_V1 已执行 cleanup 停用。'
+ END
+WHERE data_source = 'dev_seed'
+ AND seed_batch_no = 'DEV_HOME_SERVICE_PACKAGE_V1';
+
+COMMIT;
\ No newline at end of file
diff --git a/mall_sql/tests/dev_home_service_package_pricing_seed.sql b/mall_sql/tests/dev_home_service_package_pricing_seed.sql
new file mode 100644
index 00000000..17fe080d
--- /dev/null
+++ b/mall_sql/tests/dev_home_service_package_pricing_seed.sql
@@ -0,0 +1,231 @@
+-- ================================================================
+-- DEV / TEST SEED DATA ONLY
+-- 居家服务套餐价格联调测试数据,禁止用于生产真实支付。
+-- 上线前必须执行对应 cleanup SQL 或确认所有测试记录已停用。
+-- Seed Batch: DEV_HOME_SERVICE_PACKAGE_V1
+-- ================================================================
+
+BEGIN;
+
+DO $$
+DECLARE
+ missing_services TEXT;
+BEGIN
+ SELECT string_agg(required.service_id, ', ' ORDER BY required.service_id)
+ INTO missing_services
+ FROM (
+ VALUES ('svc-001'), ('svc-002'), ('svc-003')
+ ) AS required(service_id)
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM public.hss_service_catalog c
+ WHERE c.id = required.service_id
+ AND c.deleted_at IS NULL
+ );
+
+ IF missing_services IS NOT NULL THEN
+ RAISE EXCEPTION '缺少 hss_service_catalog 服务目录数据: %', missing_services;
+ END IF;
+END;
+$$;
+
+INSERT INTO public.hss_service_packages (
+ id,
+ service_id,
+ package_name,
+ package_desc,
+ duration_minutes,
+ duration_text,
+ price,
+ list_price,
+ is_default,
+ sort_no,
+ status,
+ effective_at,
+ expires_at,
+ data_source,
+ seed_batch_no,
+ remark
+) VALUES
+ (
+ 'hspkg-dev-svc001-60',
+ 'svc-001',
+ '【测试】基础上门护理 60分钟',
+ '开发联调用基础套餐,验证默认推荐、待付款金额和订单快照。',
+ 60,
+ '60分钟',
+ 138.00,
+ 168.00,
+ true,
+ 10,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc001-120',
+ 'svc-001',
+ '【测试】基础上门护理 120分钟',
+ '开发联调用延长套餐,验证多套餐展示、排序和金额差异。',
+ 120,
+ '120分钟',
+ 188.00,
+ 228.00,
+ false,
+ 20,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc001-150',
+ 'svc-001',
+ '【测试】基础上门护理 150分钟',
+ '开发联调用加强套餐,验证高价区间和时长展示。',
+ 150,
+ '150分钟',
+ 236.00,
+ 268.00,
+ false,
+ 30,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc002-90',
+ 'svc-002',
+ '【测试】康复训练指导 90分钟',
+ '开发联调用单一可售套餐,验证单服务单套餐场景。',
+ 90,
+ '90分钟',
+ 260.00,
+ 320.00,
+ true,
+ 10,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc002-120-off',
+ 'svc-002',
+ '【测试】康复训练指导 120分钟 已停用',
+ '开发联调停用套餐,验证消费者查询不会返回下架数据。',
+ 120,
+ '120分钟',
+ 318.00,
+ 360.00,
+ false,
+ 20,
+ 0,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '停用测试套餐,消费者页面不应展示。'
+ ),
+ (
+ 'hspkg-dev-svc002-120-expired',
+ 'svc-002',
+ '【测试】康复训练指导 120分钟 已过期',
+ '开发联调过期套餐,验证有效期过滤。',
+ 120,
+ '120分钟',
+ 299.00,
+ 350.00,
+ false,
+ 30,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ TIMESTAMPTZ '2026-05-01 00:00:00+08',
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '已过期测试套餐,消费者页面不应展示。'
+ ),
+ (
+ 'hspkg-dev-svc003-60',
+ 'svc-003',
+ '【测试】慢病健康随访 60分钟',
+ '开发联调用基础随访套餐,验证低价区间。',
+ 60,
+ '60分钟',
+ 108.00,
+ 128.00,
+ true,
+ 10,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc003-90',
+ 'svc-003',
+ '【测试】慢病健康随访 90分钟',
+ '开发联调用标准随访套餐,验证中间价档位。',
+ 90,
+ '90分钟',
+ 128.00,
+ 158.00,
+ false,
+ 20,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ ),
+ (
+ 'hspkg-dev-svc003-120',
+ 'svc-003',
+ '【测试】慢病健康随访 120分钟',
+ '开发联调用加长随访套餐,验证高价区间和长时长展示。',
+ 120,
+ '120分钟',
+ 166.00,
+ 198.00,
+ false,
+ 30,
+ 1,
+ TIMESTAMPTZ '2026-01-01 00:00:00+08',
+ NULL,
+ 'dev_seed',
+ 'DEV_HOME_SERVICE_PACKAGE_V1',
+ '仅用于开发联调,禁止生产真实支付;上线前必须停用或删除。'
+ )
+ON CONFLICT (id) DO UPDATE SET
+ service_id = EXCLUDED.service_id,
+ package_name = EXCLUDED.package_name,
+ package_desc = EXCLUDED.package_desc,
+ duration_minutes = EXCLUDED.duration_minutes,
+ duration_text = EXCLUDED.duration_text,
+ price = EXCLUDED.price,
+ list_price = EXCLUDED.list_price,
+ is_default = EXCLUDED.is_default,
+ sort_no = EXCLUDED.sort_no,
+ status = EXCLUDED.status,
+ effective_at = EXCLUDED.effective_at,
+ expires_at = EXCLUDED.expires_at,
+ data_source = EXCLUDED.data_source,
+ seed_batch_no = EXCLUDED.seed_batch_no,
+ remark = EXCLUDED.remark,
+ deleted_at = NULL,
+ updated_at = now();
+
+COMMIT;
\ No newline at end of file
diff --git a/pages/mall/consumer/home-service/apply.uvue b/pages/mall/consumer/home-service/apply.uvue
index 2aae131d..063fa3dd 100644
--- a/pages/mall/consumer/home-service/apply.uvue
+++ b/pages/mall/consumer/home-service/apply.uvue
@@ -37,7 +37,8 @@
-
+
+ 请至少提前30分钟预约,以便安排服务人员到达现场。
{{ item.label }}
{{ item.dateText }}
+ {{ item.weekday }}
@@ -61,9 +63,18 @@
{{ item.label }}
-
- 期望时间
-
+
+ 其他上门时间
+
+
+ {{ customSlotDisplayText }}
+
+
@@ -112,45 +123,88 @@ import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePageScaffold from '@/components/homeService/ServicePageScaffold.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
-import { createHomeServiceApplication, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
+import { createHomeServiceApplication, fetchHomeServiceCatalog, fetchHomeServicePackages } from '@/services/homeServiceService.uts'
import { HomeServiceApplicationDraftType, HomeServiceCatalogType } from '@/types/home-service.uts'
-import { BookingDayOptionType, BookingTimeSlotType, getBookingDayOptions, getBookingTimeSlots } from '@/utils/homeServiceUiMock.uts'
+import {
+ BookingDayOptionType,
+ BookingTimeSlotType,
+ buildBookingDays,
+ buildCustomSlots,
+ buildPresetSlots,
+ formatStandardAppointmentTime,
+ hasAnyAvailableSlots,
+ validateSelectedAppointmentTime
+} from '@/utils/homeServiceBookingTime.uts'
const services = ref>([])
const selectedServiceId = ref('svc-001')
const ageText = ref('78')
const bookingDays = ref>([])
const bookingSlots = ref>([])
-const selectedDayId = ref('day-1')
-const selectedSlotId = ref('slot-1')
+const customSlots = ref>([])
+const selectedDayId = ref('')
+const selectedSlotId = ref('')
+const selectedCustomSlotId = ref('')
+const customSlotIndex = ref(-1)
const form = reactive({
serviceId: 'svc-001',
serviceName: '基础上门护理',
+ selectedPackageId: '',
+ selectedPackageName: '',
applicantName: '李晓兰',
elderName: '李奶奶',
age: 78,
+ gender: '女',
phone: '13800138000',
address: '梅州市梅江区学海路 18 号 2 栋 602',
- preferredTime: '2026-05-14 上午',
+ preferredTime: '',
+ appointmentStartAt: 0,
+ appointmentEndAt: 0,
+ appointmentDate: '',
+ slotSource: '',
+ minAdvanceMinutesSnapshot: 30,
demandSummary: '老人需要基础照护、血压监测和跌倒风险提醒。'
} as HomeServiceApplicationDraftType)
+const selectedPackagePrice = ref('0')
+
const selectedPrice = computed((): string => {
- for (let i = 0; i < services.value.length; i++) {
- if (services.value[i].id == selectedServiceId.value) {
- return services.value[i].price.toString()
- }
+ if (selectedPackagePrice.value != '0') {
+ return selectedPackagePrice.value
}
return '0'
})
+function initDefaultDaySelection(now: Date) {
+ let firstAvailableIndex = -1
+ for (let i = 0; i < bookingDays.value.length; i++) {
+ if (hasAnyAvailableSlots(bookingDays.value[i].dateKey, now)) {
+ firstAvailableIndex = i
+ break
+ }
+ }
+ if (firstAvailableIndex >= 0) {
+ selectedDayId.value = bookingDays.value[firstAvailableIndex].id
+ bookingSlots.value = buildPresetSlots(bookingDays.value[firstAvailableIndex].dateKey, now)
+ customSlots.value = buildCustomSlots(bookingDays.value[firstAvailableIndex].dateKey, now)
+ } else {
+ selectedDayId.value = ''
+ bookingSlots.value = [] as Array
+ customSlots.value = [] as Array
+ }
+ selectedSlotId.value = ''
+ selectedCustomSlotId.value = ''
+ customSlotIndex.value = -1
+}
+
async function loadData() {
- bookingDays.value = getBookingDayOptions()
- bookingSlots.value = getBookingTimeSlots()
+ const now = new Date()
+ bookingDays.value = buildBookingDays(now)
+ initDefaultDaySelection(now)
services.value = await fetchHomeServiceCatalog()
if (services.value.length > 0) {
- selectService(services.value[0].id, services.value[0].name)
+ await selectService(services.value[0].id, services.value[0].name)
return
}
selectedServiceId.value = ''
@@ -158,43 +212,113 @@ async function loadData() {
form.serviceName = ''
}
-function selectService(serviceId: string, serviceName: string) {
+async function selectService(serviceId: string, serviceName: string) {
selectedServiceId.value = serviceId
form.serviceId = serviceId
form.serviceName = serviceName
+ form.selectedPackageId = ''
+ form.selectedPackageName = ''
+ selectedPackagePrice.value = '0'
+ const packages = await fetchHomeServicePackages(serviceId)
+ if (packages.length > 0) {
+ form.selectedPackageId = packages[0].id
+ form.selectedPackageName = packages[0].packageName
+ selectedPackagePrice.value = packages[0].price.toString()
+ }
}
-function syncPreferredTime() {
- let selectedDay = ''
+const selectedDay = computed((): BookingDayOptionType | null => {
for (let i = 0; i < bookingDays.value.length; i++) {
if (bookingDays.value[i].id == selectedDayId.value) {
- selectedDay = bookingDays.value[i].label + ' ' + bookingDays.value[i].dateText
- break
+ return bookingDays.value[i]
}
}
- let selectedSlot = ''
+ return null
+})
+
+const selectedSlot = computed((): BookingTimeSlotType | null => {
for (let i = 0; i < bookingSlots.value.length; i++) {
if (bookingSlots.value[i].id == selectedSlotId.value) {
- selectedSlot = bookingSlots.value[i].label
- break
+ return bookingSlots.value[i]
}
}
- if (selectedDay != '' && selectedSlot != '') {
- form.preferredTime = selectedDay + ' ' + selectedSlot
+ return null
+})
+
+const customSlotLabels = computed((): Array => {
+ const labels: Array = []
+ for (let i = 0; i < customSlots.value.length; i++) {
+ labels.push(customSlots.value[i].label)
+ }
+ return labels
+})
+
+const customSlotDisplayText = computed((): string => {
+ if (selectedCustomSlotId.value == '') {
+ return '请选择其他时间段'
+ }
+ for (let i = 0; i < customSlots.value.length; i++) {
+ if (customSlots.value[i].id == selectedCustomSlotId.value) {
+ return customSlots.value[i].label
+ }
+ }
+ return '请选择其他时间段'
+})
+
+function syncPreferredTime() {
+ const day = selectedDay.value
+ const slot = selectedSlot.value
+ if (day != null && slot != null) {
+ form.preferredTime = day.label + ' ' + day.dateText + ' ' + day.weekday + ' ' + slot.label
+ form.appointmentStartAt = slot.startAt
+ form.appointmentEndAt = slot.endAt
+ form.appointmentDate = day.dateKey
+ form.slotSource = slot.source
}
}
function selectDay(dayId: string) {
selectedDayId.value = dayId
+ selectedSlotId.value = ''
+ selectedCustomSlotId.value = ''
+ customSlotIndex.value = -1
+ const day = selectedDay.value
+ if (day != null) {
+ const now = new Date()
+ bookingSlots.value = buildPresetSlots(day.dateKey, now)
+ customSlots.value = buildCustomSlots(day.dateKey, now)
+ }
syncPreferredTime()
}
function selectSlot(slotId: string, available: boolean) {
if (!available) {
- uni.showToast({ title: '该时段暂不可约', icon: 'none' })
+ uni.showToast({ title: '该时间段已无法预约,请选择稍晚时间', icon: 'none' })
return
}
selectedSlotId.value = slotId
+ selectedCustomSlotId.value = ''
+ customSlotIndex.value = -1
+ syncPreferredTime()
+}
+
+function handleCustomSlotChange(e: any) {
+ const detail = e.detail
+ const index = typeof detail.value == 'number' ? detail.value : parseInt(detail.value)
+ if (isNaN(index) || index < 0 || index >= customSlots.value.length) {
+ return
+ }
+ const slot = customSlots.value[index]
+ if (slot == null) {
+ return
+ }
+ if (!slot.available) {
+ uni.showToast({ title: '该时间段已无法预约,请选择稍晚时间', icon: 'none' })
+ return
+ }
+ selectedCustomSlotId.value = slot.id
+ selectedSlotId.value = slot.id
+ customSlotIndex.value = index
syncPreferredTime()
}
@@ -203,10 +327,29 @@ async function submitApplication() {
uni.showToast({ title: '当前没有可预约的服务项目', icon: 'none' })
return
}
- if (form.applicantName == '' || form.elderName == '' || form.phone == '' || form.address == '' || form.preferredTime == '') {
+ if (form.applicantName == '' || form.elderName == '' || form.phone == '' || form.address == '') {
uni.showToast({ title: '请补全申请信息', icon: 'none' })
return
}
+ const now = new Date()
+ const day = selectedDay.value
+ const slot = selectedSlot.value
+ const validation = validateSelectedAppointmentTime(day, slot, now)
+ if (!validation.valid) {
+ uni.showToast({ title: validation.message, icon: 'none' })
+ if (day != null && slot != null && !slot.available) {
+ bookingSlots.value = buildPresetSlots(day.dateKey, now)
+ customSlots.value = buildCustomSlots(day.dateKey, now)
+ selectedSlotId.value = ''
+ selectedCustomSlotId.value = ''
+ customSlotIndex.value = -1
+ }
+ return
+ }
+ const standardTime = formatStandardAppointmentTime(day, slot)
+ if (standardTime != '') {
+ form.preferredTime = standardTime
+ }
const parsedAge = parseInt(ageText.value)
form.age = isNaN(parsedAge) ? 0 : parsedAge
@@ -460,4 +603,39 @@ onLoad(() => {
font-weight: 700;
color: #ffffff;
}
+
+.booking-hint {
+ font-size: 24rpx;
+ color: #64748b;
+ margin-bottom: 18rpx;
+ line-height: 34rpx;
+}
+
+.custom-slot-section {
+ margin-top: 10rpx;
+}
+
+.custom-slot-title {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #16324f;
+ margin-bottom: 14rpx;
+}
+
+.custom-slot-picker {
+ height: 82rpx;
+ border-radius: 22rpx;
+ background: #f8fbfc;
+ border-width: 1rpx;
+ border-style: solid;
+ border-color: #eef2f6;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+}
+
+.custom-slot-picker-text {
+ font-size: 24rpx;
+ color: #476072;
+}
\ No newline at end of file
diff --git a/pages/mall/consumer/home-service/order-detail.uvue b/pages/mall/consumer/home-service/order-detail.uvue
index ca58157b..720f0ec2 100644
--- a/pages/mall/consumer/home-service/order-detail.uvue
+++ b/pages/mall/consumer/home-service/order-detail.uvue
@@ -36,6 +36,9 @@
{{ detail.staffPhone }}
+
+ {{ detail.summary }}
+
@@ -73,9 +76,22 @@
+
+ {{ consumerViewState.exceptionTitle }}
+ {{ consumerViewState.exceptionDesc }}
+
+ 异常原因:
+ {{ consumerViewState.exceptionReason }}
+
+ 状态更新时间:{{ consumerViewState.statusUpdatedAt }}
+
+
- 再次预约
+ 再次预约
+ 再次预约
去验收反馈
+ 重新选择时间
+ 重新派单
联系客服
@@ -83,14 +99,15 @@
@@ -291,4 +491,66 @@ onShow(() => {
padding: 120rpx 0;
align-items: center;
}
+
+.exception-panel {
+ background: #ffffff;
+ border-radius: 32rpx;
+ padding: 28rpx;
+ box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.06);
+ margin-bottom: 24rpx;
+ display: flex;
+ flex-direction: column;
+}
+
+.exception-title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #b45309;
+ margin-bottom: 12rpx;
+}
+
+.exception-desc {
+ font-size: 24rpx;
+ color: #66788a;
+ line-height: 36rpx;
+ margin-bottom: 16rpx;
+}
+
+.exception-reason {
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin-bottom: 12rpx;
+}
+
+.exception-reason-label {
+ font-size: 24rpx;
+ color: #66788a;
+ font-weight: 600;
+}
+
+.exception-reason-value {
+ font-size: 24rpx;
+ color: #b45309;
+}
+
+.exception-update-time {
+ font-size: 22rpx;
+ color: #94a3b8;
+}
+
+.dispatch-fail-banner {
+ margin-top: 18rpx;
+ padding: 18rpx 24rpx;
+ background: #fff7ed;
+ border-radius: 16rpx;
+ border-width: 1rpx;
+ border-style: solid;
+ border-color: #fed7aa;
+}
+
+.dispatch-fail-text {
+ font-size: 26rpx;
+ color: #c2410c;
+ line-height: 40rpx;
+}
\ No newline at end of file
diff --git a/pages/mall/consumer/home-service/service-detail.uvue b/pages/mall/consumer/home-service/service-detail.uvue
index 24ba786f..8fd819d8 100644
--- a/pages/mall/consumer/home-service/service-detail.uvue
+++ b/pages/mall/consumer/home-service/service-detail.uvue
@@ -85,7 +85,8 @@
-
+
+ 请至少提前30分钟预约,以便安排服务人员到达现场。
{{ item.label }}
+
+ 其他上门时间
+
+
+ {{ customSlotDisplayText }}
+
+
+
@@ -221,7 +235,10 @@
@click="selectServicePackage(item.id)"
>
- {{ item.name }}
+
+ {{ item.name }}
+ {{ item.sourceTag }}
+
¥{{ formatPrice(item.price) }}
{{ item.desc }}
@@ -271,7 +288,7 @@
{{ selectedTimeText }}
- 立即预约
+ 提交预约
@@ -279,25 +296,33 @@
@@ -1011,6 +1253,7 @@ onShow(() => {
.booking-slot-grid,
.agency-top-row,
.action-row,
+.package-name-row,
.package-top-row {
flex-direction: row;
align-items: center;
@@ -1023,6 +1266,14 @@ onShow(() => {
justify-content: space-between;
}
+.package-name-row {
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-right: 16rpx;
+}
+
.summary-cover {
width: 144rpx;
height: 144rpx;
@@ -1085,6 +1336,16 @@ onShow(() => {
box-sizing: border-box;
}
+.package-source-tag {
+ padding: 6rpx 12rpx;
+ border-radius: 999rpx;
+ font-size: 20rpx;
+ color: #b45309;
+ background: #fff7ed;
+ margin-left: 12rpx;
+ margin-bottom: 12rpx;
+}
+
.summary-tag {
background: #ffffff;
color: #ff5a7a;
@@ -1390,4 +1651,39 @@ onShow(() => {
font-weight: 700;
color: #ffffff;
}
+
+.booking-hint {
+ font-size: 24rpx;
+ color: #64748b;
+ margin-bottom: 18rpx;
+ line-height: 34rpx;
+}
+
+.custom-slot-section {
+ margin-top: 10rpx;
+}
+
+.custom-slot-title {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #16324f;
+ margin-bottom: 14rpx;
+}
+
+.custom-slot-picker {
+ height: 82rpx;
+ border-radius: 22rpx;
+ background: #f8fbfc;
+ border-width: 1rpx;
+ border-style: solid;
+ border-color: #eef2f6;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+}
+
+.custom-slot-picker-text {
+ font-size: 24rpx;
+ color: #476072;
+}
\ No newline at end of file
diff --git a/pages/mall/consumer/orders.uvue b/pages/mall/consumer/orders.uvue
index bf2d7037..a19d50c5 100644
--- a/pages/mall/consumer/orders.uvue
+++ b/pages/mall/consumer/orders.uvue
@@ -175,6 +175,7 @@ import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
import { formatCountdownHM, getRemainingSeconds, getUnifiedDisplayState, isOrderPayExpired, isPendingPayOrder, ORDER_STATUS_CANCELLED, ORDER_STATUS_PENDING, ORDER_STATUS_TIMEOUT_LEGACY, ORDER_TIMEOUT_CANCEL_REASON, PAYMENT_STATUS_TIMEOUT, PAYMENT_STATUS_UNPAID, type OrderStatusSource } from '@/utils/orderStatus.uts'
+import { dispatchPaidHomecareOrder, showHomecareDispatchFailureModal } from '@/services/serviceOrderService.uts'
type BusinessTabItem = {
id: string,
@@ -223,7 +224,9 @@ type OrderItem = {
merchant_id: string,
shop_name: string,
products: OrderProduct[],
- service_info: UnifiedServiceInfo | null
+ service_info: UnifiedServiceInfo | null,
+ dispatch_status: string,
+ dispatch_error_message: string
}
type UnifiedOrdersCursorResult = {
@@ -713,7 +716,9 @@ const mapRawOrdersToViewModels = (rawOrders: UTSJSONObject[]): OrderItem[] => {
merchant_id: merchantId ?? '',
shop_name: shopName,
products: productsList,
- service_info: serviceInfo
+ service_info: serviceInfo,
+ dispatch_status: orderObj.getString('dispatch_status') ?? '',
+ dispatch_error_message: orderObj.getString('dispatch_error_message') ?? ''
})
}
return mappedOrders
@@ -1333,7 +1338,11 @@ const getOrderStatusText = (order: OrderItem): string => {
if (displayState == 'cancelled' || displayState == 'expired') return '已取消'
if (order.biz_type === 'service') {
- if (order.status == 1) return '待付款'
+ if (order.payment_status == 1 && order.status == 1) return '待付款'
+ if (order.payment_status == 2 && (order.dispatch_status == 'pending' || order.dispatch_status == '')) return '待派单'
+ if (order.payment_status == 2 && order.dispatch_status == 'dispatching') return '正在派单'
+ if (order.payment_status == 2 && order.dispatch_status == 'failed') return '派单未成功'
+ if (order.payment_status == 2 && order.dispatch_status == 'assigned') return '已派单'
if (order.status == 2) return '待接单'
if (order.status == 3) return '待服务'
if (order.status == 4) return '服务中'
@@ -1648,6 +1657,50 @@ const rebookService = (order: OrderItem) => {
uni.showToast({ title: '暂无可预约服务', icon: 'none' })
}
+let isRedispatching = false
+
+const redispatchServiceOrder = async (order: OrderItem) => {
+ if (isRedispatching) {
+ return
+ }
+ isRedispatching = true
+ uni.showLoading({ title: '正在重新派单', mask: true })
+ try {
+ const result = await dispatchPaidHomecareOrder(order.id)
+ uni.hideLoading()
+ if (result.success) {
+ uni.showToast({ title: '派单成功', icon: 'success' })
+ setTimeout(() => {
+ uni.navigateTo({ url: `/pages/mall/consumer/home-service/order-detail?id=${order.id}` })
+ }, 800)
+ return
+ }
+ showHomecareDispatchFailureModal(order.id, result, (id: string) => {
+ const targetOrder = getLoadedOrderById(id)
+ if (targetOrder != null) {
+ redispatchServiceOrder(targetOrder)
+ }
+ })
+ } catch (e) {
+ uni.hideLoading()
+ console.error('[redispatchServiceOrder] 重新派单异常:', e)
+ uni.showModal({
+ title: '派单服务异常',
+ content: '派单服务暂时异常,请稍后重试',
+ showCancel: true,
+ cancelText: '稍后再试',
+ confirmText: '重新派单',
+ success: (res) => {
+ if (res.confirm) {
+ redispatchServiceOrder(order)
+ }
+ }
+ })
+ } finally {
+ isRedispatching = false
+ }
+}
+
const viewSimilarService = (order: OrderItem) => {
const serviceName = order.service_info != null ? order.service_info.service_name : ''
const url = serviceName != ''
@@ -1727,6 +1780,11 @@ const getOrderActions = (order: OrderItem): OrderActionItem[] => {
}
if (order.biz_type === 'service') {
+ if (order.payment_status == 2 && order.dispatch_status == 'failed') {
+ actions.push({ id: 'view-detail', name: '查看详情', kind: 'secondary' })
+ actions.push({ id: 'redispatch', name: '重新派单', kind: 'primary' })
+ return actions
+ }
if (order.status === 2) {
actions.push({ id: 'cancel', name: '取消预约', kind: 'secondary' })
actions.push({ id: 'contact-service', name: '联系客服', kind: 'secondary' })
@@ -1797,6 +1855,8 @@ const onOrderActionTap = (order: OrderItem, actionId: string) => {
uni.showToast({ title: '服务评价功能待接入', icon: 'none' })
} else if (actionId === 'rebook') {
rebookService(order)
+ } else if (actionId === 'redispatch') {
+ redispatchServiceOrder(order)
}
}
diff --git a/pages/mall/consumer/payment.uvue b/pages/mall/consumer/payment.uvue
index db6b3de0..8228c65d 100644
--- a/pages/mall/consumer/payment.uvue
+++ b/pages/mall/consumer/payment.uvue
@@ -272,6 +272,7 @@ import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
import GuessYouLike from '@/components/mall/GuessYouLike/GuessYouLike.uvue'
import { formatCountdownHMS, getUnifiedDisplayState, ORDER_PAY_TIMEOUT_SECONDS, ORDER_STATUS_CANCELLED, ORDER_STATUS_PAID_OR_SHIPPING, ORDER_STATUS_PENDING, ORDER_STATUS_TIMEOUT_LEGACY, ORDER_TIMEOUT_CANCEL_REASON, PAYMENT_STATUS_PAID, PAYMENT_STATUS_TIMEOUT, PAYMENT_STATUS_UNPAID, type OrderStatusSource } from '@/utils/orderStatus.uts'
+import { dispatchPaidHomecareOrder, showHomecareDispatchFailureModal } from '@/services/serviceOrderService.uts'
type PaymentMethodType = {
id: string
@@ -430,6 +431,7 @@ const paymentMethods = ref>([])
const selectedMethod = ref('wechat')
const userBalance = ref(0)
const isPaying = ref(false)
+const isDispatchSubmitting = ref(false)
const showPassword = ref(false)
const password = ref('')
const addressInfo = ref(createEmptyAddress())
@@ -1469,6 +1471,42 @@ const confirmPayment = async () => {
uni.$emit('orderUpdated', { orderId: orderId.value, status: ORDER_STATUS_PAID_OR_SHIPPING, paymentStatus: PAYMENT_STATUS_PAID })
+ if (source.value == 'service' || bizType.value == 'service') {
+ isDispatchSubmitting.value = true
+ uni.showLoading({ title: '正在安排服务', mask: true })
+ try {
+ const dispatchResult = await dispatchPaidHomecareOrder(orderId.value)
+ uni.hideLoading()
+ if (dispatchResult.success) {
+ uni.redirectTo({
+ url: '/pages/mall/consumer/home-service/order-detail?id=' + orderId.value
+ })
+ return
+ }
+ showHomecareDispatchFailureModal(orderId.value, dispatchResult, (id: string) => {
+ retryHomecareDispatch(id)
+ })
+ } catch (e) {
+ uni.hideLoading()
+ console.error('[confirmPayment] 派单异常:', e)
+ uni.showModal({
+ title: '派单服务异常',
+ content: '派单服务暂时异常,请稍后重试',
+ showCancel: true,
+ cancelText: '稍后再试',
+ confirmText: '重新派单',
+ success: (res) => {
+ if (res.confirm) {
+ retryHomecareDispatch(orderId.value)
+ }
+ }
+ })
+ } finally {
+ isDispatchSubmitting.value = false
+ }
+ return
+ }
+
setTimeout(() => {
uni.redirectTo({
url: '/pages/mall/consumer/payment-success?orderId=' + orderId.value
@@ -1545,6 +1583,44 @@ const forgotPassword = () => {
})
}
+const retryHomecareDispatch = async (targetOrderId: string) => {
+ if (isDispatchSubmitting.value) {
+ return
+ }
+ isDispatchSubmitting.value = true
+ uni.showLoading({ title: '正在重新派单', mask: true })
+ try {
+ const result = await dispatchPaidHomecareOrder(targetOrderId)
+ uni.hideLoading()
+ if (result.success) {
+ uni.redirectTo({
+ url: '/pages/mall/consumer/home-service/order-detail?id=' + targetOrderId
+ })
+ return
+ }
+ showHomecareDispatchFailureModal(targetOrderId, result, (id: string) => {
+ retryHomecareDispatch(id)
+ })
+ } catch (e) {
+ uni.hideLoading()
+ console.error('[retryHomecareDispatch] 重新派单异常:', e)
+ uni.showModal({
+ title: '派单服务异常',
+ content: '派单服务暂时异常,请稍后重试',
+ showCancel: true,
+ cancelText: '稍后再试',
+ confirmText: '重新派单',
+ success: (res) => {
+ if (res.confirm) {
+ retryHomecareDispatch(targetOrderId)
+ }
+ }
+ })
+ } finally {
+ isDispatchSubmitting.value = false
+ }
+}
+
const goToOrderList = () => {
uni.redirectTo({
url: '/pages/mall/consumer/orders'
diff --git a/services/homeServiceService.uts b/services/homeServiceService.uts
index 587a7eb6..8b077ea9 100644
--- a/services/homeServiceService.uts
+++ b/services/homeServiceService.uts
@@ -1,3 +1,15 @@
+// =============================================================================
+// LEGACY/TODO 全局声明:
+// 本文件中所有直接 update ec_care_tasks / insert hc_work_order_events /
+// insert hc_work_order_exceptions / insert ec_care_records 的逻辑均为旧链路。
+// 按新架构,状态流转与事件留痕必须由后端 RPC 统一处理,前端不得直接写表。
+// 已改造的函数(如 submitWorkerCheckIn / submitWorkerServiceRecord / submitWorkerException /
+// completeWorkerTask)已切换为 rpc_delivery_* 或标记为 GAP。
+// 尚未改造的 admin 函数(submitAdminAssessment / submitAdminServicePlan /
+// submitAdminRectification / submitAdminSettlementArchive 等)仍保留旧逻辑,
+// 但已增加 TODO 注释,待后端提供对应 RPC 后统一替换。
+// =============================================================================
+
import {
HomeServiceAcceptanceType,
HomeServiceAdminApplicationType,
@@ -6,6 +18,7 @@ import {
HomeServiceCatalogType,
HomeServiceCaseType,
HomeServiceOverviewCardType,
+ HomeServicePackageType,
HomeServicePlanType,
HomeServiceRectificationType,
HomeServiceSettlementType,
@@ -15,7 +28,9 @@ import {
import {
confirmServiceOrder,
createServiceOrder,
+ getHomecareOrderDisplayStatus,
getServiceOrderDetail,
+ HOMECARE_DISPATCH_STATUS_FAILED,
listConsumerServiceOrders,
rejectServiceOrderAcceptance
} from '@/services/serviceOrderService.uts'
@@ -96,6 +111,24 @@ function parseCatalogItem(source: any): HomeServiceCatalogType {
}
}
+function parsePackageItem(source: any): HomeServicePackageType {
+ return {
+ id: readString(source, 'id'),
+ serviceId: readString(source, 'service_id'),
+ packageName: readString(source, 'package_name'),
+ packageDesc: readString(source, 'package_desc'),
+ durationMinutes: readNumber(source, 'duration_minutes'),
+ durationText: readString(source, 'duration_text'),
+ price: readNumber(source, 'price'),
+ listPrice: readNumber(source, 'list_price'),
+ isDefault: readString(source, 'is_default') == 'true' || plainObject(source)['is_default'] === true,
+ sortNo: readNumber(source, 'sort_no'),
+ dataSource: readString(source, 'data_source'),
+ seedBatchNo: readString(source, 'seed_batch_no'),
+ remark: readString(source, 'remark')
+ }
+}
+
function createTimeline(title1: string, title2: string, title3: string): Array {
return [
{
@@ -410,6 +443,49 @@ export async function fetchHomeServiceCatalog(): Promise> {
+ if (serviceId == '') {
+ return [] as Array
+ }
+ const response = await supa
+ .from('hss_service_packages')
+ .select('id, service_id, package_name, package_desc, duration_minutes, duration_text, price, list_price, is_default, sort_no, data_source, seed_batch_no, remark, effective_at, expires_at')
+ .eq('service_id', serviceId)
+ .eq('status', 1)
+ .is('deleted_at', null)
+ .order('sort_no', { ascending: true })
+ .execute()
+ if (response.error != null || response.data == null || !Array.isArray(response.data)) {
+ return [] as Array
+ }
+ const result = [] as Array
+ for (let i = 0; i < response.data.length; i++) {
+ const item = response.data[i]
+ const effectiveAt = readString(item, 'effective_at')
+ const expiresAt = readString(item, 'expires_at')
+ const effectiveMs = effectiveAt != '' ? Date.parse(effectiveAt) : 0
+ const expiresMs = expiresAt != '' ? Date.parse(expiresAt) : 0
+ const nowMs = Date.now()
+ if (effectiveMs > 0 && effectiveMs > nowMs) {
+ continue
+ }
+ if (expiresMs > 0 && expiresMs <= nowMs) {
+ continue
+ }
+ result.push(parsePackageItem(item))
+ }
+ result.sort((left: HomeServicePackageType, right: HomeServicePackageType): number => {
+ if (left.isDefault && !right.isDefault) {
+ return -1
+ }
+ if (!left.isDefault && right.isDefault) {
+ return 1
+ }
+ return left.sortNo - right.sortNo
+ })
+ return result
+}
+
export async function fetchConsumerHomeServiceCases(): Promise> {
const orders = await listConsumerServiceOrders()
const result = [] as Array
@@ -436,9 +512,28 @@ export async function createHomeServiceApplication(draft: HomeServiceApplication
break
}
}
- if (matchedService != null && draft.serviceAddressSnapshot != null) {
+ const packages = await fetchHomeServicePackages(draft.serviceId)
+ let matchedPackage: HomeServicePackageType | null = null
+ for (let i = 0; i < packages.length; i++) {
+ if (packages[i].id == draft.selectedPackageId) {
+ matchedPackage = packages[i]
+ break
+ }
+ }
+ if (matchedService != null && matchedPackage != null && draft.serviceAddressSnapshot != null) {
+ const serviceSnapshot: HomeServiceCatalogType = {
+ id: matchedService.id,
+ name: matchedService.name,
+ category: matchedService.category,
+ price: matchedPackage.price,
+ durationText: matchedPackage.durationText != '' ? matchedPackage.durationText : matchedService.durationText,
+ summary: matchedPackage.packageDesc != '' ? matchedPackage.packageDesc : matchedService.summary,
+ tags: matchedService.tags,
+ suitableFor: matchedService.suitableFor
+ }
const createdOrder = await createServiceOrder({
- service: matchedService,
+ service: serviceSnapshot,
+ packageInfo: matchedPackage,
address: {
addressId: draft.serviceAddressSnapshot.addressId,
contactName: draft.serviceAddressSnapshot.contactName,
@@ -557,12 +652,18 @@ function mapLogsToTimeline(logs: Array): Array {
}
async function completeWorkerTask(taskId: string): Promise {
- const completedAt = nowIso()
- if (await isCareTask(taskId)) {
- await supa.from('ec_care_tasks').update({
- status: 'ACCEPTANCE_PENDING',
- service_completed_at: completedAt,
- acceptance_pending_at: completedAt,
- updated_at: completedAt
- }).eq('id', taskId).execute()
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: taskId,
- from_status: 'ORDER_IN_SERVICE',
- to_status: 'ACCEPTANCE_PENDING',
- actor_id: getCurrentUserId(),
- actor_role: 'merchant',
- action: 'finish_service',
- remark: '服务记录和凭证已经提交。',
- created_at: completedAt
- }).execute()
- } else {
- await supa.from('hss_service_orders').update({
- status: 'pending_acceptance',
- completed_at: completedAt,
- pending_acceptance_at: completedAt,
- updated_at: completedAt
- }).eq('id', taskId).execute()
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: taskId,
- from_status: 'in_service',
- to_status: 'pending_acceptance',
- operator_id: getCurrentUserId(),
- operator_role: 'merchant',
- remark: '服务记录和凭证已经提交。',
- created_at: completedAt
- }).execute()
+ // LEGACY/TODO: 已切换为调用 rpc_delivery_finish_service。
+ const { data, error } = await supa.rpc('rpc_delivery_finish_service', {
+ p_order_id: taskId,
+ p_payload: {} as any
+ } as any)
+ if (error != null) {
+ console.error('[homeServiceService] finish_service rpc failed:', error)
}
return await fetchWorkerTaskDetail(taskId)
}
@@ -876,161 +950,60 @@ export async function advanceWorkerTask(taskId: string): Promise {
- const checkedInAt = nowIso()
- if (await isCareTask(taskId)) {
- const recordId = buildId('care-checkin')
- await supa.from('ec_care_records').insert({
- id: recordId,
- task_id: taskId,
- record_type: 'checkin',
- started_at: checkedInAt,
- checked_in_at: checkedInAt,
- location_text: note,
- remark: note,
- created_at: checkedInAt,
- updated_at: checkedInAt
- }).execute()
- await supa.from('ec_care_tasks').update({
- status: 'ORDER_IN_SERVICE',
- checked_in_at: checkedInAt,
- service_started_at: checkedInAt,
- updated_at: checkedInAt
- }).eq('id', taskId).execute()
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: taskId,
- from_status: 'ORDER_ACCEPTED',
- to_status: 'ORDER_IN_SERVICE',
- actor_id: getCurrentUserId(),
- actor_role: 'merchant',
- action: 'checkin_task',
- remark: note == '' ? '已完成签到,开始执行服务。' : note,
- created_at: checkedInAt
- }).execute()
- } else {
- const recordId = buildId('ser')
- await supa.from('hss_service_execution_records').upsert({
- id: recordId,
- order_id: taskId,
- assignment_id: '',
- checkin_time: checkedInAt,
- checkin_address: note,
- service_started_at: checkedInAt,
- remark: note,
- created_at: checkedInAt,
- updated_at: checkedInAt
- }).execute()
- await supa.from('hss_service_orders').update({
- status: 'in_service',
- arrived_at: checkedInAt,
- service_started_at: checkedInAt,
- updated_at: checkedInAt
- }).eq('id', taskId).execute()
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: taskId,
- from_status: 'accepted',
- to_status: 'in_service',
- operator_id: getCurrentUserId(),
- operator_role: 'merchant',
- remark: note == '' ? '已完成签到,开始执行服务。' : note,
- created_at: checkedInAt
- }).execute()
+ // LEGACY/TODO: 已切换为调用 rpc_delivery_checkin_order + rpc_delivery_start_service。
+ const checkinResult = await supa.rpc('rpc_delivery_checkin_order', {
+ p_order_id: taskId,
+ p_payload: {
+ location: {} as any,
+ photos: [] as Array,
+ note: note != null ? note : ''
+ } as any
+ } as any)
+ if (checkinResult.error != null) {
+ console.error('[homeServiceService] checkin rpc failed:', checkinResult.error)
+ }
+ const startResult = await supa.rpc('rpc_delivery_start_service', {
+ p_order_id: taskId
+ } as any)
+ if (startResult.error != null) {
+ console.error('[homeServiceService] start_service rpc failed:', startResult.error)
}
return await fetchWorkerTaskDetail(taskId)
}
export async function submitWorkerServiceRecord(taskId: string, summary: string): Promise {
- const detail = await getServiceOrderDetail(taskId)
- if (detail == null) {
- return null
- }
- const now = nowIso()
- const recordId = detail.executionRecord != null && detail.executionRecord.id != '' ? detail.executionRecord.id : buildId('worker-rec')
- if (await isCareTask(taskId)) {
- await supa.from('ec_care_records').upsert({
- id: recordId,
- task_id: taskId,
- record_type: 'service',
- started_at: detail.executionRecord != null ? detail.executionRecord.serviceStartedAt : detail.serviceStartedAt,
- summary: summary,
- remark: summary,
- created_at: detail.executionRecord != null && detail.executionRecord.createdAt != '' ? detail.executionRecord.createdAt : now,
- updated_at: now
- }).execute()
- await supa.from('ec_care_tasks').update({
- status: 'ORDER_IN_SERVICE',
- updated_at: now
- }).eq('id', taskId).execute()
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: taskId,
- from_status: 'ORDER_IN_SERVICE',
- to_status: 'ORDER_IN_SERVICE',
- actor_id: getCurrentUserId(),
- actor_role: 'merchant',
- action: 'save_record',
- remark: summary == '' ? '已保存服务记录。' : summary,
- created_at: now
- }).execute()
- } else {
- await supa.from('hss_service_execution_records').upsert({
- id: recordId,
- order_id: taskId,
- assignment_id: detail.currentAssignmentId,
- service_started_at: detail.executionRecord != null ? detail.executionRecord.serviceStartedAt : detail.serviceStartedAt,
- summary: summary,
- remark: summary,
- created_at: detail.executionRecord != null && detail.executionRecord.createdAt != '' ? detail.executionRecord.createdAt : now,
- updated_at: now
- }).execute()
+ // LEGACY/TODO: 已切换为调用 rpc_delivery_save_progress。
+ const { data, error } = await supa.rpc('rpc_delivery_save_progress', {
+ p_order_id: taskId,
+ p_payload: {
+ items: [] as any,
+ serviceSummary: summary != null ? summary : '',
+ progressNote: summary != null ? summary : ''
+ } as any
+ } as any)
+ if (error != null) {
+ console.error('[homeServiceService] save_progress rpc failed:', error)
}
return await fetchWorkerTaskDetail(taskId)
}
export async function submitWorkerException(taskId: string, exceptionType: string, description: string): Promise {
- const now = nowIso()
- if (await isCareTask(taskId)) {
- await supa.from('hc_work_order_exceptions').insert({
- id: buildId('hc-ex'),
- task_id: taskId,
- exception_type: exceptionType,
- description: description,
- occurred_at: now,
- created_by: getCurrentUserId(),
- created_at: now,
- updated_at: now
- }).execute()
- await supa.from('ec_care_tasks').update({
- status: 'ORDER_EXCEPTION',
- updated_at: now
- }).eq('id', taskId).execute()
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: taskId,
- from_status: '',
- to_status: 'ORDER_EXCEPTION',
- actor_id: getCurrentUserId(),
- actor_role: 'merchant',
- action: 'report_exception',
- remark: exceptionType + ':' + description,
- created_at: now
- }).execute()
- } else {
- await supa.from('hss_service_orders').update({
- status: 'exception',
- updated_at: now
- }).eq('id', taskId).execute()
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: taskId,
- from_status: 'in_service',
- to_status: 'exception',
- operator_id: getCurrentUserId(),
- operator_role: 'merchant',
- remark: exceptionType + ':' + description,
- created_at: now
- }).execute()
+ // LEGACY/TODO: 已切换为调用 rpc_delivery_submit_exception。
+ const { data, error } = await supa.rpc('rpc_delivery_submit_exception', {
+ p_order_id: taskId,
+ p_payload: {
+ exceptionType: exceptionType != null ? exceptionType : 'other',
+ description: description != null ? description : '',
+ occurredAt: nowIso(),
+ locationText: '',
+ images: [] as any,
+ needPlatformIntervention: false,
+ requestCancelOrder: false,
+ requestReschedule: false
+ } as any
+ } as any)
+ if (error != null) {
+ console.error('[homeServiceService] submit_exception rpc failed:', error)
}
return await fetchWorkerTaskDetail(taskId)
}
@@ -1056,30 +1029,10 @@ export async function submitAdminAssessment(caseId: string, riskLevel: string, c
requirementTags: order.serviceSnapshot.tags,
updatedAt: nowIso()
}
- if (await isCareTask(caseId)) {
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: caseId,
- from_status: order.status,
- to_status: order.status,
- actor_id: getCurrentUserId(),
- actor_role: 'admin',
- action: 'submit_assessment',
- remark: encodeAdminRemark(ADMIN_ASSESSMENT_PREFIX, payload),
- created_at: nowIso()
- }).execute()
- } else {
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: caseId,
- from_status: order.status,
- to_status: order.status,
- operator_id: getCurrentUserId(),
- operator_role: 'admin',
- remark: encodeAdminRemark(ADMIN_ASSESSMENT_PREFIX, payload),
- created_at: nowIso()
- }).execute()
- }
+ // TODO/GAP: admin 提交评估暂无后端 RPC(ec_care_tasks 新链)。
+ // 禁止前端直接 insert hc_work_order_events 作为评估留痕。
+ // 如需启用,请后端补充 rpc_admin_submit_assessment(case_id, payload)。
+ console.warn('[GAP] submitAdminAssessment 暂不可用(新链):缺少 admin 评估提交 RPC')
return buildAssessmentDetail(order, payload)
}
@@ -1112,30 +1065,10 @@ export async function submitAdminServicePlan(
planSummary,
updatedAt: nowIso()
}
- if (await isCareTask(caseId)) {
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: caseId,
- from_status: order.status,
- to_status: order.status,
- actor_id: getCurrentUserId(),
- actor_role: 'admin',
- action: 'submit_plan',
- remark: encodeAdminRemark(ADMIN_PLAN_PREFIX, payload),
- created_at: nowIso()
- }).execute()
- } else {
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: caseId,
- from_status: order.status,
- to_status: order.status,
- operator_id: getCurrentUserId(),
- operator_role: 'admin',
- remark: encodeAdminRemark(ADMIN_PLAN_PREFIX, payload),
- created_at: nowIso()
- }).execute()
- }
+ // TODO/GAP: admin 提交服务计划暂无后端 RPC(ec_care_tasks 新链)。
+ // 禁止前端直接 insert hc_work_order_events 作为计划留痕。
+ // 如需启用,请后端补充 rpc_admin_submit_service_plan(case_id, payload)。
+ console.warn('[GAP] submitAdminServicePlan 暂不可用(新链):缺少 admin 服务计划提交 RPC')
return buildPlanDetail(order, payload)
}
@@ -1200,42 +1133,10 @@ export async function submitAdminRectification(caseId: string, issueSummary: str
status: 'closed',
updatedAt: nowIso()
}
- if (await isCareTask(caseId)) {
- const reopenedAt = nowIso()
- await supa.from('ec_care_tasks').update({
- status: 'ACCEPTANCE_PENDING',
- acceptance_pending_at: reopenedAt,
- updated_at: reopenedAt
- }).eq('id', caseId).execute()
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: caseId,
- from_status: 'ACCEPTANCE_REJECTED',
- to_status: 'ACCEPTANCE_PENDING',
- actor_id: getCurrentUserId(),
- actor_role: 'admin',
- action: 'submit_rectification',
- remark: encodeAdminRemark(ADMIN_RECTIFICATION_PREFIX, payload),
- created_at: reopenedAt
- }).execute()
- } else {
- const reopenedAt = nowIso()
- await supa.from('hss_service_orders').update({
- status: 'pending_acceptance',
- pending_acceptance_at: reopenedAt,
- updated_at: reopenedAt
- }).eq('id', caseId).execute()
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: caseId,
- from_status: order.status,
- to_status: 'pending_acceptance',
- operator_id: getCurrentUserId(),
- operator_role: 'admin',
- remark: encodeAdminRemark(ADMIN_RECTIFICATION_PREFIX, payload),
- created_at: reopenedAt
- }).execute()
- }
+ // TODO/GAP: admin 提交整改暂无后端 RPC(ec_care_tasks 新链)。
+ // 禁止前端直接 update ec_care_tasks / insert hc_work_order_events。
+ // 如需启用,请后端补充 rpc_admin_submit_rectification(case_id, payload)。
+ console.warn('[GAP] submitAdminRectification 暂不可用(新链):缺少 admin 整改提交 RPC')
const latest = await getServiceOrderDetail(caseId)
if (latest == null) {
return buildRectificationDetail(order, payload, issueSummary)
diff --git a/services/serviceOrderService.uts b/services/serviceOrderService.uts
index 71676a44..13163311 100644
--- a/services/serviceOrderService.uts
+++ b/services/serviceOrderService.uts
@@ -2,7 +2,7 @@ import supa from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId } from '@/utils/store.uts'
import { getCurrentUser } from '@/utils/store.uts'
import type { UserAddress } from '@/utils/supabaseService.uts'
-import type { HomeServiceCatalogType } from '@/types/home-service.uts'
+import type { HomeServiceCatalogType, HomeServicePackageType } from '@/types/home-service.uts'
import type { DeliveryServiceRecordType } from '@/types/delivery.uts'
import {
getServiceOrderStatusText,
@@ -16,8 +16,28 @@ import {
type ServiceReviewType
} from '@/types/service-order.uts'
+export type HomecareDispatchResult = {
+ success: boolean
+ code: string
+ message: string
+ display_type: string
+ retryable: boolean
+ dispatch_status?: string
+ order_id?: string
+ assignment_id?: string
+ staff_id?: string
+ station_id?: string
+ dispatch_distance_km?: number
+}
+
+export const HOMECARE_DISPATCH_STATUS_PENDING = 'pending'
+export const HOMECARE_DISPATCH_STATUS_DISPATCHING = 'dispatching'
+export const HOMECARE_DISPATCH_STATUS_ASSIGNED = 'assigned'
+export const HOMECARE_DISPATCH_STATUS_FAILED = 'failed'
+
export type CreateServiceOrderParams = {
service: HomeServiceCatalogType
+ packageInfo: HomeServicePackageType
address: ServiceOrderAddressSnapshotType
recipientName: string
recipientPhone: string
@@ -29,6 +49,42 @@ export type CreateServiceOrderParams = {
remark: string
}
+function buildServiceSnapshot(params: CreateServiceOrderParams): any {
+ return {
+ serviceId: params.service.id,
+ serviceName: params.service.name,
+ category: params.service.category,
+ price: params.packageInfo.price,
+ durationText: params.packageInfo.durationText != '' ? params.packageInfo.durationText : params.service.durationText,
+ summary: params.packageInfo.packageDesc != '' ? params.packageInfo.packageDesc : params.service.summary,
+ tags: params.service.tags,
+ suitableFor: params.service.suitableFor,
+ packageId: params.packageInfo.id,
+ packageName: params.packageInfo.packageName,
+ packagePrice: params.packageInfo.price,
+ packageListPrice: params.packageInfo.listPrice,
+ packageDataSource: params.packageInfo.dataSource,
+ packageSeedBatchNo: params.packageInfo.seedBatchNo
+ } as any
+}
+
+function buildPricingSnapshot(params: CreateServiceOrderParams): any {
+ return {
+ service_id: params.service.id,
+ service_name: params.service.name,
+ package_id: params.packageInfo.id,
+ package_name: params.packageInfo.packageName,
+ package_desc: params.packageInfo.packageDesc,
+ duration_minutes: params.packageInfo.durationMinutes,
+ duration_text: params.packageInfo.durationText,
+ price: params.packageInfo.price,
+ list_price: params.packageInfo.listPrice,
+ data_source: params.packageInfo.dataSource,
+ seed_batch_no: params.packageInfo.seedBatchNo,
+ remark: params.packageInfo.remark
+ } as any
+}
+
const HOMECARE_DISPATCH_CANDIDATE_RPC = 'rpc_homecare_dispatch_candidate'
function nowText(): string {
@@ -89,8 +145,15 @@ function normalizeAppointmentTime(value: string): string | null {
if (text == '') {
return null
}
- if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(\s*-\s*\d{2}:\d{2})?$/.test(text)) {
- return text
+ const fullRangeMatch = text.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})(\s*-\s*\d{2}:\d{2})?$/)
+ if (fullRangeMatch != null) {
+ const datePart = fullRangeMatch[1] ?? ''
+ const startTime = fullRangeMatch[2] ?? ''
+ const parsed = Date.parse(datePart + 'T' + startTime + ':00')
+ if (!isNaN(parsed)) {
+ return new Date(parsed).toISOString()
+ }
+ return datePart + ' ' + startTime
}
if (/^\d{4}-\d{2}-\d{2}(\s+(上午|下午|晚上))$/.test(text)) {
return text
@@ -119,9 +182,12 @@ function normalizeAppointmentTime(value: string): string | null {
const rangeMatch = text.match(/(\d{2}:\d{2})(\s*-\s*\d{2}:\d{2})?/)
if (rangeMatch != null) {
const startTime = rangeMatch[1] ?? ''
- const endRange = rangeMatch[2] ?? ''
if (startTime != '') {
- return year + '-' + month + '-' + day + ' ' + startTime + endRange.replace(/\s+/g, '')
+ const parsed = Date.parse(year + '-' + month + '-' + day + 'T' + startTime + ':00')
+ if (!isNaN(parsed)) {
+ return new Date(parsed).toISOString()
+ }
+ return year + '-' + month + '-' + day + ' ' + startTime
}
}
const tailText = text.substring(text.indexOf(month + '/' + day) + 5).trim()
@@ -223,7 +289,7 @@ function shouldBypassEcServiceRequestCreate(error: any): boolean {
|| hasMissingColumnError(error, 'contact_phone')
}
-function shouldUseCareTaskPath(orderId: string): boolean {
+export function shouldUseCareTaskPath(orderId: string): boolean {
return isUuidLike(orderId)
}
@@ -327,7 +393,7 @@ function buildEcCareTaskPayload(params: CreateServiceOrderParams, userId: string
service_catalog_id: params.service.id,
service_name: params.service.name,
service_category: params.service.category,
- service_snapshot_json: params.service as any,
+ service_snapshot_json: buildServiceSnapshot(params),
elder_name: params.recipientName,
elder_phone: params.recipientPhone,
elder_age: params.recipientAge,
@@ -357,7 +423,7 @@ function buildEcCareTaskPayloadWithoutAddress(params: CreateServiceOrderParams,
service_catalog_id: params.service.id,
service_name: params.service.name,
service_category: params.service.category,
- service_snapshot_json: params.service as any,
+ service_snapshot_json: buildServiceSnapshot(params),
elder_name: params.recipientName,
elder_phone: params.recipientPhone,
elder_age: params.recipientAge,
@@ -479,6 +545,8 @@ function parseServiceOrder(item: any, logs: Array,
appointmentTime: readString(item, 'appointment_time'),
remark: readString(item, 'remark'),
status: normalizeServiceOrderStatus(readString(item, 'status')),
+ paymentStatus: readNumber(item, 'payment_status'),
+ payExpireAt: readString(item, 'pay_expire_at'),
currentAssignmentId: readString(item, 'current_assignment_id'),
currentStaffId: readString(item, 'current_staff_id'),
acceptedAt: readString(item, 'accepted_at'),
@@ -496,35 +564,23 @@ function parseServiceOrder(item: any, logs: Array,
logs,
executionRecord: null,
evidenceFiles: [] as Array,
- review
+ review,
+ dispatchStatus: readString(item, 'dispatch_status'),
+ dispatchErrorCode: readString(item, 'dispatch_error_code'),
+ dispatchErrorMessage: readString(item, 'dispatch_error_message')
}
}
async function insertLegacyStatusLog(orderId: string, fromStatus: string, toStatus: ServiceOrderStatus, operatorId: string, operatorRole: string, remark: string): Promise {
- await supa.from('hss_service_order_status_logs').insert({
- id: buildId('slog'),
- order_id: orderId,
- from_status: fromStatus,
- to_status: toStatus,
- operator_id: operatorId == '' ? null : operatorId,
- operator_role: operatorRole,
- remark,
- created_at: new Date().toISOString()
- }).execute()
+ // LEGACY/TODO: 旧链路直接写 hss_service_order_status_logs 已停用。
+ // 状态事件应由后端 RPC 统一写入 hc_work_order_events / hss_service_order_status_logs。
+ console.warn('[LEGACY] insertLegacyStatusLog skipped for', orderId, fromStatus, toStatus, remark)
}
async function insertWorkOrderEvent(taskId: string, fromStatus: string, toStatus: string, actorId: string, actorRole: string, action: string, remark: string): Promise {
- await supa.from('hc_work_order_events').insert({
- id: buildId('hc-event'),
- task_id: taskId,
- from_status: fromStatus == '' ? null : fromStatus,
- to_status: toStatus,
- actor_id: actorId == '' ? null : actorId,
- actor_role: actorRole,
- action,
- remark,
- created_at: nowIso()
- }).execute()
+ // LEGACY/TODO: 前端直接 insert hc_work_order_events 已严格禁止。
+ // 状态事件必须由后端 RPC 统一写入。
+ console.warn('[LEGACY] insertWorkOrderEvent skipped for', taskId, fromStatus, toStatus, action, remark)
}
function readFirstString(source: any, keys: Array): string {
@@ -748,10 +804,18 @@ async function getCareTaskDetail(taskId: string): Promise {
+ // LEGACY/TODO: 本函数为前端直接 INSERT ec_service_requests + ec_care_tasks 的过渡逻辑。
+ // 按新架构,履约工单应在支付完成后由后端 RPC 生成/激活,前端不得直接创建。
+ // 当前因缺少后端接口(rpc_consumer_create_homecare_task 或支付回调自动创建)暂时保留,
+ // 但已移除前端直接派单(不再写入 assigned_to + ORDER_ASSIGNED)。
if (ecServiceRequestCreateUnavailable) {
return null
}
@@ -808,19 +872,8 @@ async function tryCreateCareTask(params: CreateServiceOrderParams): Promise {
+ if (orderId == null || orderId.trim() == '') {
+ return {
+ success: false,
+ code: 'ORDER_ID_REQUIRED',
+ message: '订单信息异常,请返回后重试',
+ display_type: 'modal',
+ retryable: false
+ }
+ }
+
+ // LEGACY/TODO: rpc_homecare_auto_dispatch 当前只操作 hss_service_orders(旧交易链)。
+ // 对于 ec_care_tasks 新链(UUID 格式订单 ID),不要调用旧 RPC,避免错误回写旧表。
+ if (shouldUseCareTaskPath(orderId)) {
+ return {
+ success: true,
+ code: 'SYNC_IN_PROGRESS',
+ message: '付款成功,服务安排信息正在同步中,请稍后在我的服务中查看。',
+ display_type: 'toast',
+ retryable: false,
+ dispatch_status: HOMECARE_DISPATCH_STATUS_PENDING,
+ order_id: orderId
+ }
+ }
+
+ const { data, error } = await supa.rpc('rpc_homecare_auto_dispatch', {
+ p_order_id: orderId
+ } as any)
+
+ if (error != null) {
+ console.error('[homecare-dispatch] rpc failed:', error)
+ return {
+ success: false,
+ code: 'RPC_EXECUTION_FAILED',
+ message: '派单服务暂时异常,请稍后重试',
+ display_type: 'modal',
+ retryable: true,
+ dispatch_status: 'failed',
+ order_id: orderId
+ }
+ }
+
+ if (data == null) {
+ return {
+ success: false,
+ code: 'RPC_EMPTY_RESULT',
+ message: '未获取到派单结果,请稍后重试',
+ display_type: 'modal',
+ retryable: true,
+ dispatch_status: 'failed',
+ order_id: orderId
+ }
+ }
+
+ const result = plainObject(data)
+ const successValue = result['success']
+ const isSuccess = successValue === true || successValue === 'true' || (typeof successValue === 'boolean' && successValue)
+ const code = readString(result, 'code')
+ const message = readString(result, 'message')
+ const displayType = readString(result, 'display_type')
+ const retryable = result['retryable'] === true || result['retryable'] === 'true'
+ const dispatchStatus = readString(result, 'dispatch_status')
+
+ if (isSuccess || code == 'DISPATCH_ASSIGNED' || code == 'ALREADY_ASSIGNED' || code == 'ALREADY_ASSIGNED_RECOVERED') {
+ return {
+ success: true,
+ code: code != '' ? code : 'DISPATCH_ASSIGNED',
+ message: message != '' ? message : '系统已为您匹配服务人员',
+ display_type: displayType != '' ? displayType : 'none',
+ retryable: false,
+ dispatch_status: dispatchStatus != '' ? dispatchStatus : 'assigned',
+ order_id: readString(result, 'order_id'),
+ assignment_id: readString(result, 'assignment_id'),
+ staff_id: readString(result, 'staff_id'),
+ station_id: readString(result, 'station_id'),
+ dispatch_distance_km: readNumber(result, 'dispatch_distance_km')
+ }
+ }
+
+ return {
+ success: false,
+ code: code != '' ? code : 'DISPATCH_FAILED',
+ message: message != '' ? message : '派单失败,请稍后重试',
+ display_type: displayType != '' ? displayType : 'modal',
+ retryable: retryable,
+ dispatch_status: dispatchStatus != '' ? dispatchStatus : 'failed',
+ order_id: readString(result, 'order_id')
+ }
+}
+
+export function showHomecareDispatchFailureModal(orderId: string, result: HomecareDispatchResult, retryCallback: (id: string) => void): void {
+ const code = result.code
+ let title = '操作失败'
+ let allowRetry = false
+
+ if (code == 'ORDER_ID_REQUIRED') {
+ title = '操作失败'
+ allowRetry = false
+ } else if (code == 'UNAUTHENTICATED') {
+ title = '请重新登录'
+ allowRetry = false
+ } else if (code == 'USER_PROFILE_NOT_FOUND') {
+ title = '账户异常'
+ allowRetry = false
+ } else if (code == 'ORDER_NOT_FOUND') {
+ title = '订单异常'
+ allowRetry = false
+ } else if (code == 'ORDER_ACCESS_DENIED') {
+ title = '无权操作'
+ allowRetry = false
+ } else if (code == 'ORDER_NOT_PAID') {
+ title = '暂不能派单'
+ allowRetry = false
+ } else if (code == 'ORDER_STATUS_NOT_DISPATCHABLE') {
+ title = '暂不能派单'
+ allowRetry = false
+ } else if (code == 'NO_ONLINE_STAFF' || code == 'NO_STAFF_IN_SERVICE_STATION' || code == 'NO_QUALIFIED_STAFF' || code == 'NO_NEARBY_STAFF' || code == 'ALL_ELIGIBLE_STAFF_BUSY') {
+ title = '暂未匹配成功'
+ allowRetry = true
+ } else if (code == 'DISPATCH_CONFLICT_RETRY') {
+ title = '请重新尝试'
+ allowRetry = true
+ } else if (code == 'RPC_EXECUTION_FAILED' || code == 'RPC_EMPTY_RESULT') {
+ title = '派单服务异常'
+ allowRetry = true
+ } else {
+ title = '操作失败'
+ allowRetry = result.retryable
+ }
+
+ if (!allowRetry) {
+ uni.showModal({
+ title: title,
+ content: result.message,
+ showCancel: false,
+ confirmText: '我知道了'
+ })
+ return
+ }
+
+ uni.showModal({
+ title: title,
+ content: result.message,
+ showCancel: true,
+ cancelText: '稍后再试',
+ confirmText: '重新派单',
+ success: (res) => {
+ if (res.confirm) {
+ retryCallback(orderId)
+ }
+ }
+ })
+}
+
export async function createServiceOrder(params: CreateServiceOrderParams): Promise {
- const newTask = await tryCreateCareTask(params)
- if (newTask != null) {
- return newTask
+ // 当前下单链路分为两层:
+ // 1) 交易支付层:hss_service_orders(旧表,仍承担套餐价格快照与支付状态)。
+ // 2) 履约工单层:ec_care_tasks(新表,应由后端在支付成功后生成/激活)。
+ // 非套餐单当前走 tryCreateCareTask(LEGACY,前端直接写 ec_service_requests + ec_care_tasks),
+ // 待后端提供 rpc_consumer_create_homecare_task 后应统一切到后端创建。
+ if (params.packageInfo.id == '') {
+ const newTask = await tryCreateCareTask(params)
+ if (newTask != null) {
+ return newTask
+ }
}
const userId = getCurrentUserId()
if (userId == '') {
@@ -910,13 +1143,21 @@ export async function createServiceOrder(params: CreateServiceOrderParams): Prom
const orderNo = buildOrderNo()
const now = new Date().toISOString()
const appointmentTime = normalizeAppointmentTime(params.appointmentTime)
+ const pricingSnapshot = buildPricingSnapshot(params)
+ const serviceSnapshot = buildServiceSnapshot(params)
+ const payableAmount = params.packageInfo.price
const response = await supa.from('hss_service_orders').insert({
id: orderId,
order_no: orderNo,
user_id: userId,
service_id: params.service.id,
service_name: params.service.name,
- service_snapshot_json: params.service as any,
+ service_snapshot_json: serviceSnapshot,
+ service_package_id: params.packageInfo.id,
+ pricing_snapshot_json: pricingSnapshot,
+ original_amount: params.packageInfo.listPrice > 0 ? params.packageInfo.listPrice : payableAmount,
+ payable_amount: payableAmount,
+ total_amount: payableAmount,
service_address_id: normalizeUuidOrNull(params.address.addressId),
address_snapshot_json: params.address as any,
recipient_name: params.recipientName,
@@ -928,6 +1169,7 @@ export async function createServiceOrder(params: CreateServiceOrderParams): Prom
appointment_time: appointmentTime,
remark: params.remark,
status: 'created',
+ payment_status: 1,
created_at: now,
updated_at: now
}).execute()
@@ -936,30 +1178,8 @@ export async function createServiceOrder(params: CreateServiceOrderParams): Prom
return null
}
await insertLegacyStatusLog(orderId, '', 'created', userId, 'consumer', '创建服务订单')
- const staffObj = await getAutoAssignableStaff()
- if (staffObj != null) {
- const plainStaff = plainObject(staffObj)
- const assignmentId = buildId('sa')
- await supa.from('hss_service_assignments').insert({
- id: assignmentId,
- order_id: orderId,
- staff_id: readString(plainStaff, 'id'),
- station_id: readString(plainStaff, 'station_id') == '' ? null : readString(plainStaff, 'station_id'),
- status: 'assigned',
- assigned_at: now,
- created_at: now,
- updated_at: now
- }).execute()
- await supa.from('hss_service_orders').update({
- status: 'assigned',
- current_assignment_id: assignmentId,
- current_staff_id: readString(plainStaff, 'id'),
- updated_at: now
- }).eq('id', orderId).execute()
- await insertLegacyStatusLog(orderId, 'created', 'assigned', userId, 'system', '系统已自动派单')
- }
return await getLegacyServiceOrderDetail(orderId)
- }
+}
export async function listConsumerServiceOrders(): Promise> {
const userId = getCurrentUserId()
@@ -1010,40 +1230,11 @@ export async function saveServiceRecord(orderId: string, record: DeliveryService
}
const careTask = shouldUseCareTaskPath(orderId) ? await getCareTaskDetail(orderId) : null
if (careTask != null) {
- const savedAt = nowIso()
- const serviceContent = record.serviceContent.length > 0 ? record.serviceContent : record.serviceItems.map(item => item.name)
- const serviceSummary = record.serviceSummary != '' ? record.serviceSummary : record.processNote
- const healthMetrics = record.healthMetrics as any
- const familyConfirmation = record.familyConfirmation as any
- const insertResponse = await supa.from('ec_care_records').insert({
- id: buildId('care-record'),
- task_id: orderId,
- record_type: 'service_record',
- created_by: userId,
- service_items_json: record.serviceItems as any,
- service_content_json: serviceContent as any,
- service_summary: serviceSummary,
- process_note: record.processNote,
- elder_status: record.elderStatus,
- health_metrics_json: healthMetrics,
- materials_used: record.materialsUsed,
- abnormal_note: record.abnormalNote,
- photos_json: record.photos as any,
- staff_remark: record.staffRemark,
- family_confirmation_json: familyConfirmation,
- created_at: savedAt,
- updated_at: savedAt
- }).execute()
- if (insertResponse.error != null) {
- return null
- }
- const updateResponse = await supa.from('ec_care_tasks').update({
- updated_at: savedAt
- }).eq('id', orderId).execute()
- if (updateResponse.error != null) {
- return null
- }
- await insertWorkOrderEvent(orderId, careTask.status, careTask.status, userId, 'staff', 'save_service_record', serviceSummary == '' ? '服务记录已保存' : serviceSummary)
+ // TODO/GAP: consumer 端保存服务记录(ec_care_tasks 新链)暂无后端 RPC。
+ // 禁止前端直接 insert ec_care_records / update ec_care_tasks。
+ // 如需启用,请后端补充 rpc_consumer_save_service_record(task_id, record)。
+ console.warn('[GAP] saveServiceRecord 暂不可用(新链):缺少 consumer 保存服务记录 RPC')
+ uni.showToast({ title: '服务记录功能正在升级,请稍后重试', icon: 'none' })
return await getCareTaskDetail(orderId)
}
const current = await getLegacyServiceOrderDetail(orderId)
@@ -1083,31 +1274,14 @@ export async function confirmServiceOrder(orderId: string, rating: number, conte
}
const careTask = shouldUseCareTaskPath(orderId) ? await getCareTaskDetail(orderId) : null
if (careTask != null) {
- const acceptedAt = nowIso()
- const updateResponse = await supa.from('ec_care_tasks').update({
- status: 'ACCEPTED',
- accepted_by_family_at: acceptedAt,
- updated_at: acceptedAt
- }).eq('id', orderId).execute()
- if (updateResponse.error == null) {
- await insertWorkOrderEvent(orderId, 'ACCEPTANCE_PENDING', 'ACCEPTED', userId, 'consumer', 'accept_task', '用户确认验收')
- if (rating > 0 || content != '' || tags.length > 0) {
- await supa.from('ec_care_records').insert({
- id: buildId('care-review'),
- task_id: orderId,
- record_type: 'review',
- created_by: userId,
- rating,
- tags_json: tags as any,
- content,
- created_at: acceptedAt,
- updated_at: acceptedAt
- }).execute()
- await insertWorkOrderEvent(orderId, 'ACCEPTED', 'ACCEPTED', userId, 'consumer', 'submit_review', '用户提交评价')
- }
- return await getCareTaskDetail(orderId)
- }
+ // TODO/GAP: consumer 确认验收(ec_care_tasks 新链)暂无后端 RPC。
+ // 禁止前端直接 update ec_care_tasks / insert ec_care_records / insert hc_work_order_events。
+ // 如需启用,请后端补充 rpc_consumer_confirm_acceptance(task_id, rating, feedback, tags)。
+ console.warn('[GAP] confirmServiceOrder 暂不可用(新链):缺少 consumer 确认验收 RPC')
+ uni.showToast({ title: '验收功能正在升级,请稍后重试', icon: 'none' })
+ return await getCareTaskDetail(orderId)
}
+ // LEGACY: 以下仍为旧 hss_service_orders 链路,仅用于兼容已有交易订单。
const current = await getServiceOrderDetail(orderId)
if (current == null) {
return null
@@ -1147,31 +1321,14 @@ export async function rejectServiceOrderAcceptance(orderId: string, content: str
}
const careTask = shouldUseCareTaskPath(orderId) ? await getCareTaskDetail(orderId) : null
if (careTask != null) {
- const rejectedAt = nowIso()
- const updateResponse = await supa.from('ec_care_tasks').update({
- status: 'ACCEPTANCE_REJECTED',
- updated_at: rejectedAt
- }).eq('id', orderId).execute()
- if (updateResponse.error == null) {
- await supa.from('hc_work_order_exceptions').insert({
- id: buildId('hc-ex'),
- task_id: orderId,
- exception_type: 'acceptance_rejected',
- description: content == '' ? '用户退回整改' : content,
- occurred_at: rejectedAt,
- location_text: '',
- images_json: [] as Array,
- need_platform_intervention: false,
- request_cancel_order: false,
- request_reschedule: false,
- created_by: userId,
- created_at: rejectedAt,
- updated_at: rejectedAt
- }).execute()
- await insertWorkOrderEvent(orderId, 'ACCEPTANCE_PENDING', 'ACCEPTANCE_REJECTED', userId, 'consumer', 'reject_acceptance', content == '' ? '用户退回整改' : content)
- return await getCareTaskDetail(orderId)
- }
+ // TODO/GAP: consumer 拒绝验收(ec_care_tasks 新链)暂无后端 RPC。
+ // 禁止前端直接 update ec_care_tasks / insert hc_work_order_exceptions。
+ // 如需启用,请后端补充 rpc_consumer_reject_acceptance(task_id, feedback)。
+ console.warn('[GAP] rejectServiceOrderAcceptance 暂不可用(新链):缺少 consumer 拒绝验收 RPC')
+ uni.showToast({ title: '验收功能正在升级,请稍后重试', icon: 'none' })
+ return await getCareTaskDetail(orderId)
}
+ // LEGACY: 以下仍为旧 hss_service_orders 链路。
const current = await getServiceOrderDetail(orderId)
if (current == null) {
return null
diff --git a/types/home-service.uts b/types/home-service.uts
index 0216d9d1..7f7cb79c 100644
--- a/types/home-service.uts
+++ b/types/home-service.uts
@@ -9,6 +9,22 @@ export type HomeServiceCatalogType = {
suitableFor: string
}
+export type HomeServicePackageType = {
+ id: string
+ serviceId: string
+ packageName: string
+ packageDesc: string
+ durationMinutes: number
+ durationText: string
+ price: number
+ listPrice: number
+ isDefault: boolean
+ sortNo: number
+ dataSource: string
+ seedBatchNo: string
+ remark: string
+}
+
export type HomeServiceTimelineItemType = {
id: string
title: string
@@ -57,6 +73,8 @@ export type HomeServiceCaseType = {
staffName: string
staffPhone: string
amount: number
+ paymentStatus?: number
+ payExpireAt?: string
checkinTime: string
checkinAddress: string
serviceStartedAt: string
@@ -89,6 +107,8 @@ export type HomeServiceTaskType = {
export type HomeServiceApplicationDraftType = {
serviceId: string
serviceName: string
+ selectedPackageId: string
+ selectedPackageName: string
applicantName: string
elderName: string
age: number
@@ -96,6 +116,11 @@ export type HomeServiceApplicationDraftType = {
phone: string
address: string
preferredTime: string
+ appointmentStartAt: number
+ appointmentEndAt: number
+ appointmentDate: string
+ slotSource: string
+ minAdvanceMinutesSnapshot: number
demandSummary: string
serviceAddressSnapshot?: HomeServiceSelectedAddressType | null
}
diff --git a/types/service-order.uts b/types/service-order.uts
index 16d4f39f..d7722057 100644
--- a/types/service-order.uts
+++ b/types/service-order.uts
@@ -131,6 +131,8 @@ export type ServiceOrderType = {
appointmentTime: string
remark: string
status: ServiceOrderStatus
+ paymentStatus: number
+ payExpireAt?: string
currentAssignmentId: string
currentStaffId: string
acceptedAt: string
@@ -149,6 +151,9 @@ export type ServiceOrderType = {
executionRecord: ServiceExecutionRecordType | null
evidenceFiles: Array
review: ServiceReviewType | null
+ dispatchStatus: string
+ dispatchErrorCode: string
+ dispatchErrorMessage: string
}
export function getServiceOrderStatusText(status: ServiceOrderStatus): string {
diff --git a/utils/homeServiceBookingTime.uts b/utils/homeServiceBookingTime.uts
new file mode 100644
index 00000000..0113cb4e
--- /dev/null
+++ b/utils/homeServiceBookingTime.uts
@@ -0,0 +1,268 @@
+// ============================================================
+// 居家上门服务预约时间工具
+// 负责:动态日期生成、常用/自定义时间段生成、时间合法性校验
+// 约束:所有计算基于真实当前时间,不写死任何展示文本
+// ============================================================
+
+export const BOOKING_DAYS_LIMIT = 7
+export const MIN_ADVANCE_MINUTES = 30
+export const CUSTOM_SLOT_DURATION_MINUTES = 60
+export const CUSTOM_SLOT_STEP_MINUTES = 30
+
+export const PRESET_SLOT_RANGES = [
+ '09:00-10:00',
+ '10:00-11:00',
+ '14:00-15:00',
+ '15:00-16:00',
+ '18:00-19:00'
+]
+
+export type BookingDayOptionType = {
+ id: string
+ dateKey: string
+ label: string
+ dateText: string
+ weekday: string
+ timestamp: number
+ available: boolean
+ disabledReason: string
+}
+
+export type BookingTimeSlotType = {
+ id: string
+ label: string
+ startAt: number
+ endAt: number
+ source: string
+ available: boolean
+ disabledReason: string
+}
+
+export type BookingValidationResultType = {
+ valid: boolean
+ message: string
+}
+
+const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+
+function padLeft(num: number, length: number): string {
+ let result = String(num)
+ while (result.length < length) {
+ result = '0' + result
+ }
+ return result
+}
+
+function parseDateKey(dateKey: string): { year: number, month: number, day: number } | null {
+ if (dateKey.length != 10) {
+ return null
+ }
+ const year = parseInt(dateKey.substring(0, 4))
+ const month = parseInt(dateKey.substring(5, 7)) - 1
+ const day = parseInt(dateKey.substring(8, 10))
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
+ return null
+ }
+ return { year, month, day }
+}
+
+function parseTimeRange(range: string): { startH: number, startM: number, endH: number, endM: number } | null {
+ const parts = range.split('-')
+ if (parts.length != 2) {
+ return null
+ }
+ const startParts = parts[0].split(':')
+ const endParts = parts[1].split(':')
+ if (startParts.length != 2 || endParts.length != 2) {
+ return null
+ }
+ const startH = parseInt(startParts[0])
+ const startM = parseInt(startParts[1])
+ const endH = parseInt(endParts[0])
+ const endM = parseInt(endParts[1])
+ if (isNaN(startH) || isNaN(startM) || isNaN(endH) || isNaN(endM)) {
+ return null
+ }
+ return { startH, startM, endH, endM }
+}
+
+export function buildBookingDays(now: Date): Array {
+ const result: Array = []
+ const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)
+
+ for (let i = 0; i < BOOKING_DAYS_LIMIT; i++) {
+ const d = new Date(base.getTime() + i * 24 * 60 * 60 * 1000)
+ const year = d.getFullYear()
+ const month = d.getMonth() + 1
+ const day = d.getDate()
+ const weekdayIndex = d.getDay()
+
+ const dateKey = year + '-' + padLeft(month, 2) + '-' + padLeft(day, 2)
+ let label = ''
+ if (i == 0) {
+ label = '今天'
+ } else if (i == 1) {
+ label = '明天'
+ } else if (i == 2) {
+ label = '后天'
+ } else {
+ label = WEEKDAY_LABELS[weekdayIndex]
+ }
+
+ const dateText = padLeft(month, 2) + '月' + padLeft(day, 2) + '日'
+ const weekday = WEEKDAY_LABELS[weekdayIndex]
+
+ result.push({
+ id: 'day-' + String(i),
+ dateKey,
+ label,
+ dateText,
+ weekday,
+ timestamp: d.getTime(),
+ available: true,
+ disabledReason: ''
+ })
+ }
+ return result
+}
+
+export function buildPresetSlots(selectedDateKey: string, now: Date): Array {
+ const result: Array = []
+ const parsed = parseDateKey(selectedDateKey)
+ if (parsed == null) {
+ return result
+ }
+
+ const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
+
+ for (let i = 0; i < PRESET_SLOT_RANGES.length; i++) {
+ const range = PRESET_SLOT_RANGES[i]
+ const time = parseTimeRange(range)
+ if (time == null) {
+ continue
+ }
+
+ const startDate = new Date(parsed.year, parsed.month, parsed.day, time.startH, time.startM, 0, 0)
+ const endDate = new Date(parsed.year, parsed.month, parsed.day, time.endH, time.endM, 0, 0)
+ const startAt = startDate.getTime()
+ const endAt = endDate.getTime()
+
+ const available = startAt >= minStartTime
+ result.push({
+ id: 'preset-' + String(i),
+ label: range,
+ startAt,
+ endAt,
+ source: 'preset',
+ available,
+ disabledReason: available ? '' : '该时段距离当前不足30分钟,无法保证服务人员按时到达'
+ })
+ }
+ return result
+}
+
+export function buildCustomSlots(selectedDateKey: string, now: Date): Array {
+ const result: Array = []
+ const parsed = parseDateKey(selectedDateKey)
+ if (parsed == null) {
+ return result
+ }
+
+ const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
+
+ for (let h = 0; h < 24; h++) {
+ for (let m = 0; m < 60; m += CUSTOM_SLOT_STEP_MINUTES) {
+ const startDate = new Date(parsed.year, parsed.month, parsed.day, h, m, 0, 0)
+ const endDate = new Date(parsed.year, parsed.month, parsed.day, h, m + CUSTOM_SLOT_DURATION_MINUTES, 0, 0)
+
+ const startAt = startDate.getTime()
+ const endAt = endDate.getTime()
+ const available = startAt >= minStartTime
+
+ const startLabel = padLeft(h, 2) + ':' + padLeft(m, 2)
+ const endLabel = padLeft(endDate.getHours(), 2) + ':' + padLeft(endDate.getMinutes(), 2)
+
+ result.push({
+ id: 'custom-' + startLabel + '-' + endLabel,
+ label: startLabel + '-' + endLabel,
+ startAt,
+ endAt,
+ source: 'custom',
+ available,
+ disabledReason: available ? '' : '该时段距离当前不足30分钟,无法保证服务人员按时到达'
+ })
+ }
+ }
+ return result
+}
+
+export function hasAnyAvailableSlots(dateKey: string, now: Date): boolean {
+ const presets = buildPresetSlots(dateKey, now)
+ for (let i = 0; i < presets.length; i++) {
+ if (presets[i].available) {
+ return true
+ }
+ }
+ const customs = buildCustomSlots(dateKey, now)
+ for (let i = 0; i < customs.length; i++) {
+ if (customs[i].available) {
+ return true
+ }
+ }
+ return false
+}
+
+export function validateSelectedAppointmentTime(
+ selectedDay: BookingDayOptionType | null,
+ selectedSlot: BookingTimeSlotType | null,
+ now: Date
+): BookingValidationResultType {
+ if (selectedDay == null) {
+ return { valid: false, message: '请选择预约日期' }
+ }
+ if (selectedSlot == null) {
+ return { valid: false, message: '请选择预约时间段' }
+ }
+
+ const slotStartDate = new Date(selectedSlot.startAt)
+ const dayDate = new Date(selectedDay.timestamp)
+ const sameDay = slotStartDate.getFullYear() == dayDate.getFullYear()
+ && slotStartDate.getMonth() == dayDate.getMonth()
+ && slotStartDate.getDate() == dayDate.getDate()
+ if (!sameDay) {
+ return { valid: false, message: '所选时间段不属于当前选中的日期' }
+ }
+
+ const minStartTime = now.getTime() + MIN_ADVANCE_MINUTES * 60 * 1000
+ if (selectedSlot.startAt < minStartTime) {
+ return { valid: false, message: '当前选择的上门时间已失效,请重新选择稍晚的时间段' }
+ }
+
+ const maxDate = new Date(now.getTime() + BOOKING_DAYS_LIMIT * 24 * 60 * 60 * 1000)
+ const maxDateStart = new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate(), 0, 0, 0, 0)
+ if (selectedDay.timestamp > maxDateStart.getTime()) {
+ return { valid: false, message: '预约日期超出可预约范围' }
+ }
+
+ return { valid: true, message: '' }
+}
+
+export function formatAppointmentDisplayText(
+ day: BookingDayOptionType | null,
+ slot: BookingTimeSlotType | null
+): string {
+ if (day == null || slot == null) {
+ return '请选择上门时间'
+ }
+ return day.label + ' ' + day.dateText + ' ' + day.weekday + ' ' + slot.label
+}
+
+export function formatStandardAppointmentTime(
+ day: BookingDayOptionType | null,
+ slot: BookingTimeSlotType | null
+): string {
+ if (day == null || slot == null) {
+ return ''
+ }
+ return new Date(slot.startAt).toISOString()
+}
diff --git a/utils/homeServiceUiMock.uts b/utils/homeServiceUiMock.uts
index 218d100a..71021a93 100644
--- a/utils/homeServiceUiMock.uts
+++ b/utils/homeServiceUiMock.uts
@@ -1,4 +1,8 @@
import { HomeServiceCatalogType } from '@/types/home-service.uts'
+import {
+ BookingDayOptionType,
+ BookingTimeSlotType
+} from '@/utils/homeServiceBookingTime.uts'
// TODO: 接入真实服务分类、机构与可预约时段接口后替换这些 UI mock。
@@ -37,19 +41,6 @@ export type HomeServiceItemType = {
suitableFor: string
}
-export type BookingTimeSlotType = {
- id: string
- label: string
- available: boolean
-}
-
-export type BookingDayOptionType = {
- id: string
- label: string
- dateText: string
- weekday: string
-}
-
export type HomeServiceAgencyType = {
id: string
name: string
@@ -97,21 +88,6 @@ const FALLBACK_HOME_SERVICE_ITEMS: Array = [
{ id: 'svc-ui-004', title: '慢病随访关怀', subtitle: '血压血糖记录 用药核对 健康宣教', category: 'chronic-follow', price: 118, unit: '次', tags: ['可预约', '记录留档', '明码标价'], imageText: '随访', suitableFor: '慢病 长者 家庭' }
]
-const BOOKING_DAY_OPTIONS: Array = [
- { id: 'day-1', label: '今天', dateText: '05/18', weekday: '周一' },
- { id: 'day-2', label: '明天', dateText: '05/19', weekday: '周二' },
- { id: 'day-3', label: '后天', dateText: '05/20', weekday: '周三' },
- { id: 'day-4', label: '周四', dateText: '05/21', weekday: '周四' }
-]
-
-const BOOKING_TIME_SLOTS: Array = [
- { id: 'slot-1', label: '09:00-10:00', available: true },
- { id: 'slot-2', label: '10:00-11:00', available: true },
- { id: 'slot-3', label: '14:00-15:00', available: true },
- { id: 'slot-4', label: '15:00-16:00', available: true },
- { id: 'slot-5', label: '18:00-19:00', available: false }
-]
-
const SERVICE_GUARANTEES: Array = [
{ id: 'guarantee-1', label: '平台认证' },
{ id: 'guarantee-2', label: '明码标价' },
@@ -188,14 +164,6 @@ export function getHomeServiceItems(catalog: Array): Arr
return result
}
-export function getBookingDayOptions(): Array {
- return BOOKING_DAY_OPTIONS.map((item) => ({ ...item }))
-}
-
-export function getBookingTimeSlots(): Array {
- return BOOKING_TIME_SLOTS.map((item) => ({ ...item }))
-}
-
export function getServiceGuarantees(): Array {
return SERVICE_GUARANTEES.map((item) => ({ ...item }))
}
diff --git a/utils/homecareStatus.uts b/utils/homecareStatus.uts
new file mode 100644
index 00000000..d81bb138
--- /dev/null
+++ b/utils/homecareStatus.uts
@@ -0,0 +1,161 @@
+// =============================================================================
+// 居家服务统一状态常量与映射
+// 说明:
+// - 本文件对齐数据库 ec_care_tasks.status 口径与 ORDER_* 枚举。
+// - delivery / consumer 两端共用同一套状态常量,禁止各自维护不同文案。
+// - 状态流转必须由后端 RPC 推导,前端只做展示与权限判断。
+// =============================================================================
+
+// 工单主状态(来自 ec_care_tasks.status)
+export const ORDER_CREATED = 'ORDER_CREATED'
+export const ORDER_ASSIGNED = 'ORDER_ASSIGNED'
+export const ORDER_ACCEPTED = 'ORDER_ACCEPTED'
+export const ORDER_REJECTED = 'ORDER_REJECTED'
+export const ORDER_CHECKED_IN = 'ORDER_CHECKED_IN'
+export const ORDER_IN_SERVICE = 'ORDER_IN_SERVICE'
+export const ORDER_EXCEPTION = 'ORDER_EXCEPTION'
+export const ORDER_COMPLETED = 'ORDER_COMPLETED'
+export const ACCEPTANCE_PENDING = 'ACCEPTANCE_PENDING'
+export const ACCEPTED = 'ACCEPTED'
+export const ACCEPTANCE_REJECTED = 'ACCEPTANCE_REJECTED'
+export const SETTLEMENT_READY = 'SETTLEMENT_READY'
+export const ARCHIVED = 'ARCHIVED'
+export const ORDER_CANCELLED = 'ORDER_CANCELLED'
+export const ORDER_CLOSED = 'ORDER_CLOSED'
+
+// 合法状态集合(用于校验)
+export const HOMEcare_TASK_STATUSES: Array = [
+ ORDER_CREATED,
+ ORDER_ASSIGNED,
+ ORDER_ACCEPTED,
+ ORDER_REJECTED,
+ ORDER_CHECKED_IN,
+ ORDER_IN_SERVICE,
+ ORDER_EXCEPTION,
+ ORDER_COMPLETED,
+ ACCEPTANCE_PENDING,
+ ACCEPTED,
+ ACCEPTANCE_REJECTED,
+ SETTLEMENT_READY,
+ ARCHIVED,
+ ORDER_CANCELLED,
+ ORDER_CLOSED
+]
+
+// consumer 端展示文案
+export function getHomecareTaskStatusText(status: string): string {
+ if (status == ORDER_CREATED) return '待安排服务'
+ if (status == ORDER_ASSIGNED) return '已安排服务人员,等待接单'
+ if (status == ORDER_ACCEPTED) return '服务人员已接单,等待上门'
+ if (status == ORDER_REJECTED) return '正在重新安排人员'
+ if (status == ORDER_CHECKED_IN) return '服务人员已到达'
+ if (status == ORDER_IN_SERVICE) return '服务进行中'
+ if (status == ORDER_EXCEPTION) return '服务处理异常中'
+ if (status == ORDER_COMPLETED) return '服务已完成,待验收'
+ if (status == ACCEPTANCE_PENDING) return '待验收'
+ if (status == ACCEPTED) return '已验收'
+ if (status == ACCEPTANCE_REJECTED) return '验收未通过'
+ if (status == SETTLEMENT_READY) return '待结算'
+ if (status == ARCHIVED) return '已归档'
+ if (status == ORDER_CANCELLED) return '已取消'
+ if (status == ORDER_CLOSED) return '已关闭'
+ return status
+}
+
+// 主题色/标签色(用于 UI 标签)
+export function getHomecareTaskStatusTheme(status: string): string {
+ if (status == ORDER_CREATED) return 'warning'
+ if (status == ORDER_ASSIGNED) return 'primary'
+ if (status == ORDER_ACCEPTED) return 'primary'
+ if (status == ORDER_REJECTED) return 'danger'
+ if (status == ORDER_CHECKED_IN) return 'success'
+ if (status == ORDER_IN_SERVICE) return 'success'
+ if (status == ORDER_EXCEPTION) return 'danger'
+ if (status == ORDER_COMPLETED) return 'primary'
+ if (status == ACCEPTANCE_PENDING) return 'warning'
+ if (status == ACCEPTED) return 'success'
+ if (status == ACCEPTANCE_REJECTED) return 'danger'
+ if (status == SETTLEMENT_READY) return 'warning'
+ if (status == ARCHIVED) return 'info'
+ if (status == ORDER_CANCELLED) return 'info'
+ if (status == ORDER_CLOSED) return 'info'
+ return 'default'
+}
+
+// 给定角色与状态,返回允许的前端操作标识
+export function getHomecareTaskAllowedActions(status: string, role: string): Array {
+ const actions = [] as Array
+ if (role == 'FAMILY_USER' || role == 'consumer') {
+ if (status == ORDER_COMPLETED || status == ACCEPTANCE_PENDING) {
+ actions.push('confirm_acceptance')
+ actions.push('reject_acceptance')
+ }
+ }
+ if (role == 'HOMECARE_WORKER' || role == 'delivery' || role == 'staff') {
+ if (status == ORDER_ASSIGNED) {
+ actions.push('accept')
+ actions.push('reject')
+ }
+ if (status == ORDER_ACCEPTED) {
+ actions.push('checkin')
+ actions.push('report_exception')
+ }
+ if (status == ORDER_CHECKED_IN) {
+ actions.push('start_service')
+ actions.push('report_exception')
+ }
+ if (status == ORDER_IN_SERVICE) {
+ actions.push('save_record')
+ actions.push('upload_evidence')
+ actions.push('report_exception')
+ actions.push('finish_service')
+ }
+ }
+ if (role == 'HOMECARE_DISPATCHER' || role == 'dispatcher') {
+ if (status == ORDER_CREATED || status == ORDER_REJECTED || status == ORDER_EXCEPTION) {
+ actions.push('dispatch')
+ }
+ }
+ return actions
+}
+
+// 将旧 hss / delivery 前端状态归一化为 ORDER_* 状态
+export function normalizeToOrderStatus(raw: string): string {
+ const s = raw.trim().toUpperCase()
+ if (s == 'CREATED' || s == 'SUBMITTED') return ORDER_CREATED
+ if (s == 'PAID') return ORDER_CREATED
+ if (s == 'ASSIGNED' || s == 'PENDING_ASSIGNMENT' || s == 'PENDING_DISPATCH' || s == 'PENDING_ACCEPT') return ORDER_ASSIGNED
+ if (s == 'ACCEPTED' || s == 'PENDING_ACCEPT') return ORDER_ACCEPTED
+ if (s == 'REJECTED') return ORDER_REJECTED
+ if (s == 'DEPARTED' || s == 'ON_THE_WAY' || s == 'WAITING_DEPARTURE') return ORDER_ACCEPTED
+ if (s == 'ARRIVED' || s == 'CHECKED_IN') return ORDER_CHECKED_IN
+ if (s == 'IN_SERVICE' || s == 'SERVING') return ORDER_IN_SERVICE
+ if (s == 'COMPLETED') return ORDER_COMPLETED
+ if (s == 'PENDING_ACCEPTANCE' || s == 'PENDING_CONFIRM' || s == 'PENDING_SUBMIT') return ACCEPTANCE_PENDING
+ if (s == 'ACCEPTED_BY_USER') return ACCEPTED
+ if (s == 'REVIEWED') return ACCEPTED
+ if (s == 'SETTLED') return SETTLEMENT_READY
+ if (s == 'EXCEPTION' || s == 'ABNORMAL' || s == 'EXCEPTION_PENDING') return ORDER_EXCEPTION
+ if (s == 'CANCELLED') return ORDER_CANCELLED
+ if (s == 'ARCHIVED') return ARCHIVED
+ if (s == 'ORDER_CREATED') return ORDER_CREATED
+ if (s == 'ORDER_ASSIGNED') return ORDER_ASSIGNED
+ if (s == 'ORDER_ACCEPTED') return ORDER_ACCEPTED
+ if (s == 'ORDER_REJECTED') return ORDER_REJECTED
+ if (s == 'ORDER_CHECKED_IN') return ORDER_CHECKED_IN
+ if (s == 'ORDER_IN_SERVICE') return ORDER_IN_SERVICE
+ if (s == 'ORDER_EXCEPTION') return ORDER_EXCEPTION
+ if (s == 'ORDER_COMPLETED') return ORDER_COMPLETED
+ if (s == 'ACCEPTANCE_PENDING') return ACCEPTANCE_PENDING
+ if (s == 'ACCEPTED') return ACCEPTED
+ if (s == 'ACCEPTANCE_REJECTED') return ACCEPTANCE_REJECTED
+ if (s == 'SETTLEMENT_READY') return SETTLEMENT_READY
+ if (s == 'ORDER_CANCELLED') return ORDER_CANCELLED
+ if (s == 'ORDER_CLOSED') return ORDER_CLOSED
+ return raw
+}
+
+// 判断是否为已关闭/终态
+export function isHomecareTaskClosed(status: string): boolean {
+ return status == ARCHIVED || status == ORDER_CANCELLED || status == ORDER_CLOSED || status == ACCEPTED || status == SETTLEMENT_READY
+}
diff --git a/utils/supabaseService.uts b/utils/supabaseService.uts
index 6e46a77e..93091b47 100644
--- a/utils/supabaseService.uts
+++ b/utils/supabaseService.uts
@@ -4695,16 +4695,26 @@ class SupabaseService {
return 3
}
- private matchesServiceStatusTab(status: string, statusTab: string): boolean {
+ private matchesServiceStatusTab(status: string, statusTab: string, dispatchStatus: string = ''): boolean {
if (statusTab == 'all') return true
const normalizedStatus = this.normalizeServiceStatus(status)
if (statusTab == 'pending') return normalizedStatus == 'created'
- if (statusTab == 'accepted') return normalizedStatus == 'paid' || normalizedStatus == 'assigned'
+ if (statusTab == 'accepted') {
+ if (normalizedStatus == 'paid' && dispatchStatus != 'assigned' && dispatchStatus != 'failed') {
+ return true
+ }
+ return normalizedStatus == 'assigned'
+ }
if (statusTab == 'scheduled') return normalizedStatus == 'accepted' || normalizedStatus == 'departed'
if (statusTab == 'inservice') return normalizedStatus == 'arrived' || normalizedStatus == 'in_service'
if (statusTab == 'completed') return normalizedStatus == 'completed' || normalizedStatus == 'pending_acceptance' || normalizedStatus == 'accepted_by_user' || normalizedStatus == 'reviewed' || normalizedStatus == 'settled'
if (statusTab == 'aftersale') return false
- if (statusTab == 'inprogress') return normalizedStatus == 'paid' || normalizedStatus == 'assigned' || normalizedStatus == 'accepted' || normalizedStatus == 'departed' || normalizedStatus == 'arrived' || normalizedStatus == 'in_service'
+ if (statusTab == 'inprogress') {
+ if (normalizedStatus == 'paid' && dispatchStatus != 'failed') {
+ return true
+ }
+ return normalizedStatus == 'assigned' || normalizedStatus == 'accepted' || normalizedStatus == 'departed' || normalizedStatus == 'arrived' || normalizedStatus == 'in_service'
+ }
return true
}
@@ -4746,8 +4756,10 @@ class SupabaseService {
const orderObj = JSON.parse(JSON.stringify(rawOrder)) as UTSJSONObject
const addressSnapshotRaw = orderObj.get('address_snapshot_json')
const serviceSnapshotRaw = orderObj.get('service_snapshot_json')
+ const pricingSnapshotRaw = orderObj.get('pricing_snapshot_json')
let addressObj: UTSJSONObject | null = null
let serviceObj: UTSJSONObject | null = null
+ let pricingObj: UTSJSONObject | null = null
try {
if (addressSnapshotRaw != null) {
@@ -4775,11 +4787,33 @@ class SupabaseService {
serviceObj = null
}
+ try {
+ if (pricingSnapshotRaw != null) {
+ const pricingText = JSON.stringify(pricingSnapshotRaw)
+ if (pricingText.startsWith('"')) {
+ pricingObj = JSON.parse(orderObj.getString('pricing_snapshot_json') ?? '{}') as UTSJSONObject
+ } else {
+ pricingObj = JSON.parse(pricingText) as UTSJSONObject
+ }
+ }
+ } catch (e) {
+ pricingObj = null
+ }
+
const normalizedStatus = this.getUnifiedServiceStatusNumber(orderObj.getString('status') ?? '')
const fullAddress = addressObj != null ? (addressObj.getString('fullAddress') ?? '') : ''
const providerName = orderObj.getString('staff_name') ?? ''
const serviceName = orderObj.getString('service_name') ?? (serviceObj != null ? (serviceObj.getString('serviceName') ?? '') : '')
- const servicePrice = serviceObj != null ? (serviceObj.getNumber('price') ?? 0) : 0
+ let servicePrice = orderObj.getNumber('total_amount') ?? 0
+ if (servicePrice <= 0 && orderObj.getNumber('payable_amount') != null) {
+ servicePrice = orderObj.getNumber('payable_amount') ?? 0
+ }
+ if (servicePrice <= 0 && pricingObj != null) {
+ servicePrice = pricingObj.getNumber('price') ?? 0
+ }
+ if (servicePrice <= 0 && serviceObj != null) {
+ servicePrice = serviceObj.getNumber('price') ?? 0
+ }
const serviceInfo = new UTSJSONObject()
serviceInfo.set('service_name', serviceName)
@@ -4789,6 +4823,8 @@ class SupabaseService {
serviceInfo.set('contact_name', orderObj.getString('contact_name') ?? '')
serviceInfo.set('contact_phone', orderObj.getString('contact_phone') ?? '')
serviceInfo.set('provider_name', providerName)
+ serviceInfo.set('package_name', pricingObj != null ? (pricingObj.getString('package_name') ?? '') : '')
+ serviceInfo.set('pricing_data_source', pricingObj != null ? (pricingObj.getString('data_source') ?? '') : '')
const unifiedOrder = new UTSJSONObject()
const rawPaymentStatus = orderObj.getNumber('payment_status')
@@ -4817,10 +4853,16 @@ class SupabaseService {
unifiedOrder.set('shipping_fee', 0)
unifiedOrder.set('total_amount', servicePrice)
unifiedOrder.set('paid_amount', servicePrice)
+ unifiedOrder.set('pricing_data_source', pricingObj != null ? (pricingObj.getString('data_source') ?? '') : '')
+ const dispatchStatus = orderObj.getString('dispatch_status') ?? ''
+ const dispatchErrorMessage = orderObj.getString('dispatch_error_message') ?? ''
unifiedOrder.set('merchant_id', orderObj.getString('current_staff_id') ?? '')
unifiedOrder.set('shop_name', providerName != '' ? providerName : '康养上门服务')
unifiedOrder.set('service_info', serviceInfo)
unifiedOrder.set('ml_order_items', [] as UTSJSONObject[])
+ unifiedOrder.set('dispatch_status', dispatchStatus)
+ unifiedOrder.set('dispatch_error_code', orderObj.getString('dispatch_error_code') ?? '')
+ unifiedOrder.set('dispatch_error_message', dispatchErrorMessage)
return unifiedOrder
}
@@ -4870,7 +4912,7 @@ class SupabaseService {
if (!matchedKeyword) {
continue
}
- if (!this.matchesServiceStatusTab(rawObj.getString('status') ?? '', params.statusTab)) {
+ if (!this.matchesServiceStatusTab(rawObj.getString('status') ?? '', params.statusTab, rawObj.getString('dispatch_status') ?? '')) {
continue
}
filtered.push(this.buildUnifiedServiceOrder(rawItem))
@@ -5016,7 +5058,7 @@ class SupabaseService {
const response = await supa
.from('hss_service_orders')
- .select('status')
+ .select('status,payment_status,dispatch_status')
.eq('user_id', userId)
.is('consumer_deleted_at', null)
.limit(500)
@@ -5031,9 +5073,11 @@ class SupabaseService {
for (let i = 0; i < rawList.length; i++) {
const rawObj = JSON.parse(JSON.stringify(rawList[i])) as UTSJSONObject
const normalizedStatus = this.normalizeServiceStatus(rawObj.getString('status') ?? '')
- if (normalizedStatus == 'created') {
+ const dispatchStatus = rawObj.getString('dispatch_status') ?? ''
+ const paymentStatus = rawObj.getNumber('payment_status') ?? 0
+ if (normalizedStatus == 'created' && paymentStatus == 1) {
counts.set('pending', (counts.getNumber('pending') ?? 0) + 1)
- } else if (normalizedStatus == 'paid' || normalizedStatus == 'assigned') {
+ } else if ((normalizedStatus == 'paid' || normalizedStatus == 'assigned') && dispatchStatus != 'failed') {
counts.set('accepted', (counts.getNumber('accepted') ?? 0) + 1)
} else if (normalizedStatus == 'accepted' || normalizedStatus == 'departed') {
counts.set('scheduled', (counts.getNumber('scheduled') ?? 0) + 1)
@@ -5041,6 +5085,8 @@ class SupabaseService {
counts.set('inservice', (counts.getNumber('inservice') ?? 0) + 1)
} else if (normalizedStatus == 'completed' || normalizedStatus == 'pending_acceptance' || normalizedStatus == 'accepted_by_user' || normalizedStatus == 'reviewed' || normalizedStatus == 'settled') {
counts.set('completed', (counts.getNumber('completed') ?? 0) + 1)
+ } else if (dispatchStatus == 'failed') {
+ counts.set('accepted', (counts.getNumber('accepted') ?? 0) + 1)
}
}
return counts
@@ -5608,6 +5654,10 @@ class SupabaseService {
console.error('[payUnifiedOrder] 订单状态已变更,拒绝支付:', latestStatus, latestPaymentStatus)
return false
}
+ if (source == 'service' && (latestOrder.getString('pricing_data_source') ?? '') == 'dev_seed') {
+ console.error('[payUnifiedOrder] 测试套餐订单禁止真实支付')
+ return false
+ }
const tableName = this.getUnifiedOrderTableName(orderId, source)
const isService = tableName == 'hss_service_orders'
diff --git a/报错信息.txt b/报错信息.txt
index e69de29b..205502e3 100644
--- a/报错信息.txt
+++ b/报错信息.txt
@@ -0,0 +1,259 @@
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: ak_users filter: auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/ak_users?select=*&auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 Profile Load Result: AkReqResponse {status: 200, data: Array(1), headers: Proxy, error: null, total: 1, …}
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_catalog filter: status=eq.1&deleted_at=is.null
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_catalog?select=id%2C%20name%2C%20category%2C%20price%2C%20duration_text%2C%20summary%2C%20tags_json%2C%20suitable_for%2C%20sort_no&order=sort_no.asc&status=eq.1&deleted_at=is.null
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_packages filter: service_id=eq.svc-001&status=eq.1&deleted_at=is.null
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_packages?select=id%2C%20service_id%2C%20package_name%2C%20package_desc%2C%20duration_minutes%2C%20duration_text%2C%20price%2C%20list_price%2C%20is_default%2C%20sort_no%2C%20data_source%2C%20seed_batch_no%2C%20remark%2C%20effective_at%2C%20expires_at&order=sort_no.asc&service_id=eq.svc-001&status=eq.1&deleted_at=is.null
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_orders filter: null
+mp.esm.js:529 [ak-req] POST http://119.146.131.237:9126/rest/v1/hss_service_orders
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: return=representation
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_order_status_logs filter: null
+mp.esm.js:529 [ak-req] POST http://119.146.131.237:9126/rest/v1/hss_service_order_status_logs
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: return=representation
+mp.esm.js:529 [ak-req] POST http://119.146.131.237:9126/rest/v1/rpc/rpc_homecare_dispatch_candidate
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_assignments filter: null
+mp.esm.js:529 [ak-req] POST http://119.146.131.237:9126/rest/v1/hss_service_assignments
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: return=representation
+uni.api.esm.js:1042 POST http://119.146.131.237:9126/rest/v1/hss_service_assignments 403 (Forbidden)(env: Windows,mp,1.06.2504030; lib: 3.15.2)
+(anonymous) @ uni.api.esm.js:1042
+invokeApi @ uni.api.esm.js:330
+promiseApi @ uni.api.esm.js:889
+(anonymous) @ ak-req.uts:214
+doOnce @ ak-req.uts:213
+_loop$ @ ak-req.uts:328
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+s @ regeneratorRuntime.js?forceSync=true:1
+_ @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+request @ ak-req.uts:148
+_callee19$ @ aksupa.uts:1290
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+requestWithAutoRefresh @ aksupa.uts:1289
+_callee13$ @ aksupa.uts:1109
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+insert @ aksupa.uts:1084
+_callee$ @ aksupa.uts:469
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+execute @ aksupa.uts:369
+_callee7$ @ serviceOrderService.uts:1011
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+createServiceOrder @ serviceOrderService.uts:947
+_callee5$ @ homeServiceService.uts:520
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+createHomeServiceApplication @ homeServiceService.uts:492
+_callee3$ @ service-detail.uvue:1000
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+submitBooking @ service-detail.uvue:928
+callWithErrorHandling @ vue.runtime.esm.js:1356
+callWithAsyncErrorHandling @ vue.runtime.esm.js:1363
+invoke @ vue.runtime.esm.js:6223
+setTimeout (async)
+invoker @ vue.runtime.esm.js:6232
+mp.esm.js:529 [ak-req] HTTP error response(env: Windows,mp,1.06.2504030; lib: 3.15.2)
+(anonymous) @ mp.esm.js:529
+__f__ @ uni.api.esm.js:590
+success @ ak-req.uts:304
+(anonymous) @ uni.api.esm.js:946
+mp.esm.js:529 [ak-req] status: 403(env: Windows,mp,1.06.2504030; lib: 3.15.2)
+(anonymous) @ mp.esm.js:529
+__f__ @ uni.api.esm.js:590
+success @ ak-req.uts:305
+(anonymous) @ uni.api.esm.js:946
+mp.esm.js:529 [ak-req] url: http://119.146.131.237:9126/rest/v1/hss_service_assignments(env: Windows,mp,1.06.2504030; lib: 3.15.2)
+(anonymous) @ mp.esm.js:529
+__f__ @ uni.api.esm.js:590
+success @ ak-req.uts:306
+(anonymous) @ uni.api.esm.js:946
+mp.esm.js:529 [ak-req] body: {"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"hss_service_assignments\""}(env: Windows,mp,1.06.2504030; lib: 3.15.2)
+(anonymous) @ mp.esm.js:529
+__f__ @ uni.api.esm.js:590
+success @ ak-req.uts:307
+(anonymous) @ uni.api.esm.js:946
+mp.esm.js:529 auto assignment skipped because hss_service_assignments insert failed UniError: 请求失败: 403
+ at _construct (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/construct.js?forceSync=true:1:1227)
+ at new r (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/wrapNativeSuper.js?forceSync=true:1:1357)
+ at UniError2. (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/createSuper.js?forceSync=true:1:1176)
+ at new UniError2 (weapp:///http://127.0.0.1:52926/appservice/common/vendor.js?t=wechat&s=1780274722363&v=b8259e7320e689d2052eba7322e8d16c:890:22)
+ at Object.toUniError (weapp:///http://127.0.0.1:52926/appservice/utils/utils.js?t=wechat&s=1780274722363&v=f2308cd9303df87468154d9972a36f54:77:18)
+ at AkSupa._callee19$ (weapp:///http://127.0.0.1:52926/appservice/components/supadb/aksupa.js?t=wechat&s=1780274722363&v=5ff4c2b5987006abb13dddaa41fbfb57:1961:41)
+ at s (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/regeneratorRuntime.js?forceSync=true:1:1588)
+ at Generator. (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/regeneratorRuntime.js?forceSync=true:1:2925)
+ at Generator.next (weapp:///http://127.0.0.1:52926/appservice/@babel/runtime/helpers/regeneratorRuntime.js?forceSync=true:1:1951)
+ at fulfilled (weapp:///http://127.0.0.1:52926/appservice/common/vendor.js?t=wechat&s=1780274722363&v=b8259e7320e689d2052eba7322e8d16c:10009:24)
+(anonymous) @ mp.esm.js:529
+__f__ @ uni.api.esm.js:590
+_callee7$ @ serviceOrderService.uts:1021
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+createServiceOrder @ serviceOrderService.uts:947
+_callee5$ @ homeServiceService.uts:520
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+createHomeServiceApplication @ homeServiceService.uts:492
+_callee3$ @ service-detail.uvue:1000
+s @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+(anonymous) @ regeneratorRuntime.js?forceSync=true:1
+fulfilled @ uni.mp.esm.js:1134
+Promise.then (async)
+step @ uni.mp.esm.js:1134
+(anonymous) @ uni.mp.esm.js:1134
+__awaiter @ uni.mp.esm.js:1134
+submitBooking @ service-detail.uvue:928
+callWithErrorHandling @ vue.runtime.esm.js:1356
+callWithAsyncErrorHandling @ vue.runtime.esm.js:1363
+invoke @ vue.runtime.esm.js:6223
+setTimeout (async)
+invoker @ vue.runtime.esm.js:6232
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_orders filter: id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_orders?select=*&limit=1&id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_order_status_logs filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_order_status_logs?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_reviews filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_reviews?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_execution_records filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_execution_records?select=*&limit=1&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_evidence_files filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_evidence_files?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+[自动热重载] 已开启代码文件保存后自动热重载
+2mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: ak_users filter: auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/ak_users?select=*&auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/ak_users?select=*&auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 Profile Load Result: AkReqResponse {status: 200, data: Array(1), headers: Proxy, error: null, total: 1, …}
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_orders filter: id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_orders?select=*&limit=1&id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_order_status_logs filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_order_status_logs?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_reviews filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_reviews?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_execution_records filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_execution_records?select=*&limit=1&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_evidence_files filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_evidence_files?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+[pages/mall/consumer/home-service/order-detail] 提示: text 组件包含了长文本,可以考虑增加 user-select 属性,方便用户复制。
+mp.esm.js:529 Profile Load Result: AkReqResponse {status: 200, data: Array(1), headers: Proxy, error: null, total: 1, …}
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_orders filter: id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_orders?select=*&limit=1&id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_order_status_logs filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_order_status_logs?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_reviews filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_reviews?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_execution_records filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_execution_records?select=*&limit=1&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_evidence_files filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_evidence_files?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: ak_users filter: auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/ak_users?select=*&auth_id=eq.b653fded-7d5e-4950-aa0d-725595543e3c
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 Profile Load Result: AkReqResponse {status: 200, data: Array(1), headers: Proxy, error: null, total: 1, …}
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_orders filter: id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_orders?select=*&limit=1&id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_order_status_logs filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_order_status_logs?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_reviews filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_reviews?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_execution_records filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_execution_records?select=*&limit=1&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: count=exact
+mp.esm.js:529 [AkSupaQueryBuilder] execute - 表: hss_service_evidence_files filter: order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] GET http://119.146.131.237:9126/rest/v1/hss_service_evidence_files?select=*&order=created_at.desc&order_id=eq.so-1780300511605-53626
+mp.esm.js:529 [ak-req] apikey: eyJhbG...7890 | Authorization: Bearer eyJhbG...EWtY | auth-mode: pre-set | prefer: (none)
\ No newline at end of file