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