统一推送数据库表
This commit is contained in:
@@ -54,3 +54,12 @@ ak/
|
||||
|
||||
- 请避免提交本地环境相关配置(如 `ak/config.uts`)到仓库(已通过 `.gitignore` 处理)
|
||||
- 分包页面路径变更请同步更新 `pages.json`
|
||||
|
||||
## 📣 配送模块 — 推送与设备(整合说明)
|
||||
|
||||
项目中 `pages/mall/delivery/doc/需求文档` 目录包含多份关于配送、轨迹与推送的需求与建表示例。为了便于 DBA/后端/前端对齐,已将核心要点整合为草稿:
|
||||
|
||||
- 推送建表迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`
|
||||
- 推送完整附录(含平台侧建表示例):`pages/mall/delivery/doc/需求文档/推送与设备需求文档_含建表附录.md`
|
||||
|
||||
建议下一步:在测试库执行迁移并验证 `notify_queue` / 后台消费者流程。若需要,我可以生成迁移运行命令或提供后台消费者示例代码。
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
-- =====================================================================================
|
||||
-- Add push device table and express_notifications table
|
||||
-- 目的:保存每个用户/商家设备的推送 CID,并记录由轨迹事件触发的通知消息
|
||||
-- 创建日期:2026-02-24
|
||||
-- 注意:尽量与仓库中其它迁移风格保持一致;该脚本对已存在对象做 IF NOT EXISTS 检查。
|
||||
-- =====================================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 依赖扩展(若已存在则忽略)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- updated_at 自动维护函数(若仓库已有同名函数则不重复创建)
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF to_regprocedure('public.update_updated_at_column()') IS NULL THEN
|
||||
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $func$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
END IF;
|
||||
END $do$;
|
||||
|
||||
-- =====================================================
|
||||
-- A. push_devices:设备注册/绑定表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.push_devices (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- 关联主体(user / merchant),使用具体字段便于查询与权限控制。
|
||||
user_id UUID NULL,
|
||||
merchant_id UUID NULL,
|
||||
|
||||
-- 设备推送客户端标识(CID)由推送 SDK 提供
|
||||
cid VARCHAR(255) NOT NULL,
|
||||
|
||||
-- 平台/渠道(android / ios / web / huawei / xiaomi)
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'android',
|
||||
|
||||
-- 应用标识(用于区分不同打包的 AppID / 环境)
|
||||
appid VARCHAR(128) NOT NULL DEFAULT 'default',
|
||||
|
||||
-- 设备是否激活(登录/解绑/失效时置 false)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- 设备最后上报时间(用于清理失效设备)
|
||||
last_seen_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
|
||||
-- 注册来源(可选,用于审计)
|
||||
registration_source VARCHAR(64) NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- updated_at 触发器
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'trigger_push_devices_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER trigger_push_devices_updated_at
|
||||
BEFORE UPDATE ON public.push_devices
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 唯一约束:同一 appid 下 cid 唯一
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_push_devices_appid_cid
|
||||
ON public.push_devices(appid, cid);
|
||||
|
||||
-- 索引:按 user_id/merchant_id 查询是常用路径
|
||||
CREATE INDEX IF NOT EXISTS idx_push_devices_user_id ON public.push_devices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_devices_merchant_id ON public.push_devices(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_devices_is_active ON public.push_devices(is_active);
|
||||
|
||||
-- =====================================================
|
||||
-- B. express_notifications:物流相关推送消息中心表
|
||||
-- 用途:保存由轨迹事件触发的消息记录(用于消息中心、未读计数与幂等)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.express_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- 接收范围:user / merchant
|
||||
aud VARCHAR(16) NOT NULL,
|
||||
recipient_id UUID NOT NULL,
|
||||
|
||||
-- 业务关联(可选):订单/运单
|
||||
order_id UUID NULL,
|
||||
waybill_id UUID NULL,
|
||||
|
||||
-- 运单与承运方冗余字段,便于快速查询
|
||||
tracking_no VARCHAR(64) NULL,
|
||||
carrier VARCHAR(32) NULL,
|
||||
|
||||
-- 推送消息唯一 id(可由服务端生成并返回给客户端)
|
||||
message_id VARCHAR(128) NULL,
|
||||
|
||||
-- 事件摘要(下发给客户端的安全/脱敏文案)
|
||||
event_text_safe TEXT NULL,
|
||||
status_code VARCHAR(32) NULL,
|
||||
event_time TIMESTAMP WITH TIME ZONE NULL,
|
||||
|
||||
-- 透传/审计用 payload(不包含敏感字段,raw_payload 请勿透传给客户端)
|
||||
payload JSONB NULL,
|
||||
|
||||
-- 已读时间(null 表示未读)
|
||||
read_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
|
||||
-- 幂等键(确保同一事件只生成一条消息)
|
||||
dedupe_key VARCHAR(256) NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- updated_at 触发器
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'trigger_express_notifications_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER trigger_express_notifications_updated_at
|
||||
BEFORE UPDATE ON public.express_notifications
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 索引与约束
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_aud_recipient ON public.express_notifications(aud, recipient_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_order_id ON public.express_notifications(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_waybill_id ON public.express_notifications(waybill_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_created_at ON public.express_notifications(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_express_notifications_read_at ON public.express_notifications(read_at);
|
||||
|
||||
-- 若使用 message_id 做幂等(外部系统/队列),则建立唯一索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||
ON public.express_notifications(message_id) WHERE message_id IS NOT NULL;
|
||||
|
||||
-- =====================================================
|
||||
-- C. 兼容性与外键(若目标表存在则添加外键约束;若不存在则保留字段)
|
||||
-- 说明:为避免迁移失败,我们在这里尝试添加外键,但使用 DO $$ ... EXCEPTION 来忽略不存在表的情况。
|
||||
-- =====================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 尝试为 push_devices.user_id 添加外键(如果 ak_users 表存在)
|
||||
IF EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = 'ak_users' AND n.nspname = 'public') THEN
|
||||
BEGIN
|
||||
ALTER TABLE public.push_devices
|
||||
ADD CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES public.ak_users(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN
|
||||
-- 已存在约束则忽略
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
|
||||
-- 尝试为 express_notifications.order_id 添加外键(如果 ml_orders 表存在)
|
||||
IF EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = 'ml_orders' AND n.nspname = 'public') THEN
|
||||
BEGIN
|
||||
ALTER TABLE public.express_notifications
|
||||
ADD CONSTRAINT fk_express_notifications_order FOREIGN KEY (order_id) REFERENCES public.ml_orders(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
|
||||
-- 尝试为 express_notifications.waybill_id 添加外键(如果 platform_express_waybills 表存在)
|
||||
IF EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = 'platform_express_waybills' AND n.nspname = 'public') THEN
|
||||
BEGIN
|
||||
ALTER TABLE public.express_notifications
|
||||
ADD CONSTRAINT fk_express_notifications_waybill FOREIGN KEY (waybill_id) REFERENCES public.platform_express_waybills(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- =====================================================================================
|
||||
-- 使用说明(简要)
|
||||
-- 1) `push_devices` 存储设备 CID 与所属主体(user_id / merchant_id),提供活跃检测与解绑能力。
|
||||
-- 2) `express_notifications` 存储消息中心记录;生成通知时写入该表並异步向 `push_devices` 中匹配设备发送推送。
|
||||
-- 3) 推荐在应用层实现:当用户登出或解绑设备时将 `push_devices.is_active` 置 false。
|
||||
-- 4) 可按需要添加清理任务:清理 long-inactive 的 `push_devices`(例如 180 天未上报)。
|
||||
-- =====================================================================================
|
||||
111
pages/mall/delivery/doc/需求文档/推送与设备需求文档.md
Normal file
111
pages/mall/delivery/doc/需求文档/推送与设备需求文档.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 推送与设备需求文档
|
||||
|
||||
本文档定义平台端基于 `push_devices` 与 `express_notifications` 的推送能力需求,覆盖设备 CID 的采集/管理、物流事件驱动的消息生成与送达、隐私与验收标准。
|
||||
|
||||
关联文档:
|
||||
- 配送模块需求文档.md
|
||||
- 接口规范.md
|
||||
- 前端字段清单.md
|
||||
- 状态映射表.md
|
||||
- mall_sql/migrations/20260224_add_push_devices_and_notifications.sql
|
||||
|
||||
## 一、目标
|
||||
- 保存每个用户/商家设备的推送 CID(支持多设备、多平台、多 appid)。
|
||||
- 在轨迹事件触发时,生成消息中心记录并向对应活跃设备下发推送(通知/透传)。
|
||||
- 提供消息幂等、未读/已读管理与审计能力,且严格控制敏感字段透传。
|
||||
|
||||
## 二、范围
|
||||
- 包含:设备注册/解绑、设备活跃检测、消息表写入、消息去重、对外推送触发接口与消息中心 API。
|
||||
- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。
|
||||
|
||||
## 三、术语
|
||||
- CID:Push 平台分配的 client id。
|
||||
- 设备:一台移动设备或浏览器实例,用 `cid + appid` 唯一标识。
|
||||
- 消息(Notification):由轨迹事件驱动的逻辑消息,写入 `express_notifications` 并可同步到消息中心与 Push 通道。
|
||||
|
||||
## 四、数据表与字段(概要)
|
||||
说明:详细 DDL 请参考迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`。
|
||||
|
||||
- `push_devices`
|
||||
- `id, user_id, merchant_id, cid, platform, appid, is_active, last_seen_at, registration_source, created_at, updated_at`
|
||||
- 约束:同一 `appid` 下 `cid` 唯一;按 `user_id/merchant_id` 建索引。
|
||||
|
||||
- `express_notifications`
|
||||
- `id, aud, recipient_id, order_id, waybill_id, tracking_no, carrier, message_id, event_text_safe, status_code, event_time, payload, read_at, dedupe_key, created_at, updated_at`
|
||||
- 幂等:`message_id` 唯一(若存在)或 `dedupe_key` 在生成逻辑层保证不重复写入。
|
||||
|
||||
## 五、REST API 设计(建议)
|
||||
注:API 端点为示例,具体实现放在平台后端网关下。
|
||||
|
||||
1) 设备管理
|
||||
- `POST /api/v1/push/register` — 注册/更新设备
|
||||
请求体:`{user_id?, merchant_id?, cid, platform, appid, registration_source}`
|
||||
返回:设备记录
|
||||
|
||||
- `POST /api/v1/push/unregister` — 解绑设备
|
||||
请求体:`{cid, appid, user_id?}`
|
||||
|
||||
- `GET /api/v1/push/devices?user_id=...` — 列出某用户活跃设备
|
||||
|
||||
2) 消息与消息中心
|
||||
- `POST /api/v1/notifications/express/create` — 由后端服务写入消息并触发推送(通常由 EventProcessor 调用)
|
||||
请求体示例:
|
||||
```json
|
||||
{
|
||||
"aud":"user",
|
||||
"recipient_id":"<user_uuid>",
|
||||
"order_id":"<order_uuid>",
|
||||
"waybill_id":"<waybill_uuid>",
|
||||
"tracking_no":"YT123...",
|
||||
"carrier":"YTO",
|
||||
"message_id":"msg_...",
|
||||
"event_text_safe":"包裹正在派送中,派送员预计今日到达",
|
||||
"status_code":"OUT_FOR_DELIVERY",
|
||||
"event_time":"2026-02-24T10:00:00+08:00",
|
||||
"payload": {"deeplink": {"path":"/pages/order/detail","query":{"order_no":"ORD_...","tab":"logistics"}}},
|
||||
"dedupe_key":"waybill|event_id_or_composite"
|
||||
}
|
||||
```
|
||||
|
||||
- `GET /api/v1/notifications?aud=user&recipient_id=...` — 列表
|
||||
- `POST /api/v1/notifications/read` — 标记已读(按 message_id 或 recipient_id+order_id 批量)
|
||||
|
||||
3) 运维/调试
|
||||
- `GET /api/v1/push/stats` — 推送成功率/失败率/未读数(用于监控)
|
||||
|
||||
## 六、消息生成与推送流程
|
||||
1) 当 `platform_express_tracking_events` 写入新事件且满足推送策略(见下),平台事件处理器调用 `express_notifications.create` 写入消息表(使用 `dedupe_key` 保证幂等)。
|
||||
2) 写入成功后,异步任务查询 `push_devices`(按 `aud/recipient_id/appid/is_active`)获取目标 `cid` 列表并调用推送通道(uni-push2 或后端统一推送服务)。
|
||||
3) 推送返回结果写入日志并根据结果更新统计;若设备失效则将 `push_devices.is_active` 置 false(或记录探测失败)。
|
||||
|
||||
推送策略建议(MVP):只在状态级变化或关键状态产生消息:`SHIPPED/OUT_FOR_DELIVERY/READY_FOR_PICKUP/DELIVERED/EXCEPTION/RETURNED`。
|
||||
|
||||
## 七、隐私与安全
|
||||
- 不通过 push 透传 `raw_payload`、完整手机号、签名、密钥等敏感信息。
|
||||
- `event_text_safe` 为由平台清洗/脱敏后的文案,任何发送给客户端的文案必须经过该字段。
|
||||
- 设备绑定时不得将密钥写入前端;CID 由客户端 SDK 提供,后端仅关联 `user_id`/`merchant_id`。
|
||||
- 审计:对 `push_devices` 的创建/更新/读取 `raw_payload` 访问均需记录审计日志(平台侧实现)。
|
||||
|
||||
## 八、非功能需求
|
||||
- 可用性:消息写入及触发应具备重试机制,推送通道建议异步队列化处理。
|
||||
- 可观测性:记录每条消息的 `created_at`, `sent_status`, `sent_at`, `response_code`(推送日志由推送通道记录)。
|
||||
- 可扩展性:支持按 `appid` 区分不同应用/环境,便于多包管理。
|
||||
|
||||
## 九、验收标准
|
||||
1) 能成功注册设备并查询到对应 `cid`(按 `user_id`)。
|
||||
2) 插入一条关键轨迹事件后:在 `express_notifications` 中新增消息且按活跃设备下发推送(模拟推送通道验证)。
|
||||
3) 相同事件重复到达不会产生重复消息(由 `dedupe_key` 或 `message_id` 保证)。
|
||||
4) 推送 payload 不包含敏感信息(检查 `payload` 字段)。
|
||||
|
||||
## 十、迁移与回滚建议
|
||||
- 在执行迁移脚本前,先在测试库运行并检查索引与触发器。
|
||||
- 若生产已有其它命名冲突,请先手工评估 `push_devices` 与 `express_notifications` 字段与现有表的兼容性。
|
||||
|
||||
## 十一、参考
|
||||
- `pages/mall/delivery/doc/需求文档/配送模块需求文档.md`
|
||||
- `pages/mall/delivery/doc/需求文档/接口规范.md`
|
||||
- `mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`
|
||||
|
||||
---
|
||||
作者:自动生成(可手动微调)
|
||||
日期:2026-02-24
|
||||
276
pages/mall/delivery/doc/需求文档/推送与设备需求文档_含建表附录.md
Normal file
276
pages/mall/delivery/doc/需求文档/推送与设备需求文档_含建表附录.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 推送与设备需求文档(含建表附录)
|
||||
|
||||
本文档为 `推送与设备需求文档.md` 的拷贝,并在末尾追加 `express_tracking_platform_upgrade.sql` 的建表示例,便于 DBA / 后端直接参考与执行。
|
||||
|
||||
---
|
||||
|
||||
(以下为需求文档原文)
|
||||
|
||||
|
||||
# 推送与设备需求文档
|
||||
|
||||
本文档定义平台端基于 `push_devices` 与 `express_notifications` 的推送能力需求,覆盖设备 CID 的采集/管理、物流事件驱动的消息生成与送达、隐私与验收标准。
|
||||
|
||||
关联文档:
|
||||
- 配送模块需求文档.md
|
||||
- 接口规范.md
|
||||
- 前端字段清单.md
|
||||
- 状态映射表.md
|
||||
- mall_sql/migrations/20260224_add_push_devices_and_notifications.sql
|
||||
|
||||
## 一、目标
|
||||
- 保存每个用户/商家设备的推送 CID(支持多设备、多平台、多 appid)。
|
||||
- 在轨迹事件触发时,生成消息中心记录并向对应活跃设备下发推送(通知/透传)。
|
||||
- 提供消息幂等、未读/已读管理与审计能力,且严格控制敏感字段透传。
|
||||
|
||||
## 二、范围
|
||||
- 包含:设备注册/解绑、设备活跃检测、消息表写入、消息去重、对外推送触发接口与消息中心 API。
|
||||
- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。
|
||||
|
||||
## 三、术语
|
||||
- CID:Push 平台分配的 client id。
|
||||
- 设备:一台移动设备或浏览器实例,用 `cid + appid` 唯一标识。
|
||||
- 消息(Notification):由轨迹事件驱动的逻辑消息,写入 `express_notifications` 并可同步到消息中心与 Push 通道。
|
||||
|
||||
## 四、数据表与字段(概要)
|
||||
说明:详细 DDL 请参考迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`。
|
||||
|
||||
- `push_devices`
|
||||
- `id, user_id, merchant_id, cid, platform, appid, is_active, last_seen_at, registration_source, created_at, updated_at`
|
||||
- 约束:同一 `appid` 下 `cid` 唯一;按 `user_id/merchant_id` 建索引。
|
||||
|
||||
- `express_notifications`
|
||||
- `id, aud, recipient_id, order_id, waybill_id, tracking_no, carrier, message_id, event_text_safe, status_code, event_time, payload, read_at, dedupe_key, created_at, updated_at`
|
||||
- 幂等:`message_id` 唯一(若存在)或 `dedupe_key` 在生成逻辑层保证不重复写入。
|
||||
|
||||
## 五、REST API 设计(建议)
|
||||
注:API 端点为示例,具体实现放在平台后端网关下。
|
||||
|
||||
1) 设备管理
|
||||
- `POST /api/v1/push/register` — 注册/更新设备
|
||||
请求体:`{user_id?, merchant_id?, cid, platform, appid, registration_source}`
|
||||
返回:设备记录
|
||||
|
||||
- `POST /api/v1/push/unregister` — 解绑设备
|
||||
请求体:`{cid, appid, user_id?}`
|
||||
|
||||
- `GET /api/v1/push/devices?user_id=...` — 列出某用户活跃设备
|
||||
|
||||
2) 消息与消息中心
|
||||
- `POST /api/v1/notifications/express/create` — 由后端服务写入消息并触发推送(通常由 EventProcessor 调用)
|
||||
请求体示例:
|
||||
```json
|
||||
{
|
||||
"aud":"user",
|
||||
"recipient_id":"<user_uuid>",
|
||||
"order_id":"<order_uuid>",
|
||||
"waybill_id":"<waybill_uuid>",
|
||||
"tracking_no":"YT123...",
|
||||
"carrier":"YTO",
|
||||
"message_id":"msg_...",
|
||||
"event_text_safe":"包裹正在派送中,派送员预计今日到达",
|
||||
"status_code":"OUT_FOR_DELIVERY",
|
||||
"event_time":"2026-02-24T10:00:00+08:00",
|
||||
"payload": {"deeplink": {"path":"/pages/order/detail","query":{"order_no":"ORD_...","tab":"logistics"}}},
|
||||
"dedupe_key":"waybill|event_id_or_composite"
|
||||
}
|
||||
```
|
||||
|
||||
- `GET /api/v1/notifications?aud=user&recipient_id=...` — 列表
|
||||
- `POST /api/v1/notifications/read` — 标记已读(按 message_id 或 recipient_id+order_id 批量)
|
||||
|
||||
3) 运维/调试
|
||||
- `GET /api/v1/push/stats` — 推送成功率/失败率/未读数(用于监控)
|
||||
|
||||
## 六、消息生成与推送流程
|
||||
1) 当 `platform_express_tracking_events` 写入新事件且满足推送策略(见下),平台事件处理器调用 `express_notifications.create` 写入消息表(使用 `dedupe_key` 保证幂等)。
|
||||
2) 写入成功后,异步任务查询 `push_devices`(按 `aud/recipient_id/appid/is_active`)获取目标 `cid` 列表并调用推送通道(uni-push2 或后端统一推送服务)。
|
||||
3) 推送返回结果写入日志并根据结果更新统计;若设备失效则将 `push_devices.is_active` 置 false(或记录探测失败)。
|
||||
|
||||
推送策略建议(MVP):只在状态级变化或关键状态产生消息:`SHIPPED/OUT_FOR_DELIVERY/READY_FOR_PICKUP/DELIVERED/EXCEPTION/RETURNED`。
|
||||
|
||||
## 七、隐私与安全
|
||||
- 不通过 push 透传 `raw_payload`、完整手机号、签名、密钥等敏感信息。
|
||||
- `event_text_safe` 为由平台清洗/脱敏后的文案,任何发送给客户端的文案必须经过该字段。
|
||||
- 设备绑定时不得将密钥写入前端;CID 由客户端 SDK 提供,后端仅关联 `user_id`/`merchant_id`。
|
||||
- 审计:对 `push_devices` 的创建/更新/读取 `raw_payload` 访问均需记录审计日志(平台侧实现)。
|
||||
|
||||
## 八、非功能需求
|
||||
- 可用性:消息写入及触发应具备重试机制,推送通道建议异步队列化处理。
|
||||
- 可观测性:记录每条消息的 `created_at`, `sent_status`, `sent_at`, `response_code`(推送日志由推送通道记录)。
|
||||
- 可扩展性:支持按 `appid` 区分不同应用/环境,便于多包管理。
|
||||
|
||||
## 九、验收标准
|
||||
1) 能成功注册设备并查询到对应 `cid`(按 `user_id`)。
|
||||
2) 插入一条关键轨迹事件后:在 `express_notifications` 中新增消息且按活跃设备下发推送(模拟推送通道验证)。
|
||||
3) 相同事件重复到达不会产生重复消息(由 `dedupe_key` 或 `message_id` 保证)。
|
||||
4) 推送 payload 不包含敏感信息(检查 `payload` 字段)。
|
||||
|
||||
## 十、迁移与回滚建议
|
||||
- 在执行迁移脚本前,先在测试库运行并检查索引与触发器。
|
||||
- 若生产已有其它命名冲突,请先手工评估 `push_devices` 与 `express_notifications` 字段与现有表的兼容性。
|
||||
|
||||
|
||||
## 十一、参考
|
||||
- `pages/mall/delivery/doc/需求文档/配送模块需求文档.md`
|
||||
- `pages/mall/delivery/doc/需求文档/接口规范.md`
|
||||
- `mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`
|
||||
|
||||
---
|
||||
|
||||
(以下为与目录中其他文档的对齐要点、触发器/队列与视图示例)
|
||||
|
||||
## 与目录中其他文档的对齐要点
|
||||
为保证与 `配送模块需求文档.md`、`接口规范.md`、`前端字段清单.md`、`状态映射表.md` 一致,本文档做如下约定并实现兼容:
|
||||
|
||||
- 事件模型:消息生成使用 `platform_express_tracking_events` 的归一化字段(`event_id/event_time/event_code/event_text/status_code/waybill_id`)。
|
||||
- 状态口径:使用 `状态映射表.md` 中定义的 `ORDER_PLACED/SHIPPED/IN_TRANSIT/OUT_FOR_DELIVERY/READY_FOR_PICKUP/DELIVERED/EXCEPTION/RETURNED` 作为 `express_notifications.status_code` 值域。
|
||||
- 字段契约:客户端仅消费 `express_notifications.event_text_safe`、`status_code`、`event_time`、`payload.deeplink`;原始 `raw_payload` 不对客户端下发(仅后台审计可见)。
|
||||
- Webhook 与控制面:推送触发入口由事件处理器(或 Mock Server 控制面)调用 `POST /api/v1/notifications/express/create` 写入消息表并触发推送。
|
||||
|
||||
## 自动化:事件入库 -> 消息生成(触发器示例)
|
||||
建议在应用层以异步任务方式实现;以下为数据库触发器/队列化的参考实现思路(Postgres + pg_notify 或写入中间队列表):
|
||||
|
||||
1) 触发器:在 `platform_express_tracking_events` 插入后触发 `notify_new_tracking_event`,把 `waybill_id/event_time/status_code/event_id` 写入 `notify_queue`(或使用 `pg_notify` 发消息)。
|
||||
2) 后台消费者订阅 `notify_queue`(或 LISTEN/NOTIFY),根据 `推送策略` 和 `recipient` 映射生成 `express_notifications` 并把实际推送任务入列(调用推送服务)。
|
||||
|
||||
示例 SQL(简化,仅供参考):
|
||||
|
||||
```sql
|
||||
-- 中间队列表(示例)
|
||||
CREATE TABLE IF NOT EXISTS public.notify_queue (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
waybill_id uuid NOT NULL,
|
||||
event_id varchar(128) NULL,
|
||||
status_code varchar(32) NULL,
|
||||
event_time timestamptz NULL,
|
||||
processed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 触发器函数:插入 notify_queue
|
||||
CREATE OR REPLACE FUNCTION public.trg_platform_event_after_insert()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.notify_queue(waybill_id,event_id,status_code,event_time)
|
||||
VALUES (NEW.waybill_id, NEW.event_id, NEW.status_code, NEW.event_time);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 建触发器(若已存在请在应用层采用幂等部署)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_platform_events_after_insert') THEN
|
||||
CREATE TRIGGER trigger_platform_events_after_insert
|
||||
AFTER INSERT ON public.platform_express_tracking_events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.trg_platform_event_after_insert();
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
说明:生产环境推荐把业务逻辑放在后台消费者(可重试、可观测),数据库触发器仅负责把事件推入可靠队列。
|
||||
|
||||
## 视图与未读计数(示例)
|
||||
为方便前端读取未读数与消息列表,建议创建以下视图或物化视图:
|
||||
|
||||
1) 用户消息视图(按 user)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW public.vw_express_notifications_for_user AS
|
||||
SELECT n.*
|
||||
FROM public.express_notifications n
|
||||
WHERE n.aud = 'user';
|
||||
```
|
||||
|
||||
2) 未读计数物化视图(按用户/商家)
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS public.mv_unread_notifications_count AS
|
||||
SELECT aud, recipient_id, count(*) FILTER (WHERE read_at IS NULL) AS unread_count
|
||||
FROM public.express_notifications
|
||||
GROUP BY aud, recipient_id;
|
||||
|
||||
-- 刷新策略:可按计划任务或在写入消息时触发快速更新
|
||||
```
|
||||
|
||||
## 示例:从事件到推送的完整查询链
|
||||
- 查找某 `order_id` 的活跃设备:
|
||||
|
||||
```sql
|
||||
SELECT p.* FROM public.push_devices p
|
||||
WHERE p.user_id = (SELECT o.user_id FROM public.ml_orders o WHERE o.id = '<order_uuid>')
|
||||
AND p.appid = 'default' AND p.is_active = true;
|
||||
```
|
||||
|
||||
- 创建消息并返回 message_id 的示例(业务层用 INSERT ... RETURNING):
|
||||
|
||||
```sql
|
||||
INSERT INTO public.express_notifications (aud, recipient_id, order_id, waybill_id, tracking_no, carrier, message_id, event_text_safe, status_code, event_time, payload, dedupe_key)
|
||||
VALUES ('user', '<user_uuid>', '<order_uuid>', '<waybill_uuid>', 'TEST_123', 'YTO', 'msg_sync_123', '包裹正在派送中', 'OUT_FOR_DELIVERY', now(), jsonb_build_object('deeplink', jsonb_build_object('path','/pages/order/detail','query',jsonb_build_object('order_no','ORD_...'))), 'dedupe_...' )
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
## 与现有文档的补充修改建议(供你选择采纳)
|
||||
- 在 `接口规范.md` 中增加一节:`消息中心/推送契约`,描述 `express_notifications` 的字段与 `POST /api/v1/notifications/express/create` 示例。
|
||||
- 在 `前端字段清单.md` 中明确 `event_text_safe` 的清洗规则与允许的占位符(例如 `{carrier}`、`{eta}`)。
|
||||
- 在 `状态映射表.md` 中加入 `推送触发级别` 一栏,列出哪些 `status_code` 会触发推送(默认为关键状态)。
|
||||
|
||||
## 下一步
|
||||
我可以:
|
||||
- 将上述触发器与视图 SQL 附加到迁移目录作为可选脚本(例如:`mall_sql/migrations/20260224_notifications_trigger_and_views.sql`),并生成 psql/PowerShell 执行命令;或
|
||||
- 只生成后台消费者示例(Node.js + supabase 或 pg 客户端),负责从 `notify_queue` 消费并调用推送通道。
|
||||
|
||||
请告诉我要先做哪一项。
|
||||
|
||||
(以下为附录:平台侧建表示例摘录)
|
||||
|
||||
```sql
|
||||
-- 第三方快递轨迹(平台侧)表结构升级 (PostgreSQL + Supabase)
|
||||
-- 用途: 引入第三方承运方运单与轨迹事件的统一入库模型
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF to_regprocedure('public.update_updated_at_column()') IS NULL THEN
|
||||
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $func$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
END IF;
|
||||
END $do$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.platform_express_waybills (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
order_id UUID NULL REFERENCES public.ml_orders(id) ON DELETE SET NULL,
|
||||
order_no VARCHAR(64) NULL,
|
||||
carrier VARCHAR(32) NOT NULL,
|
||||
tracking_no VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'mock',
|
||||
current_status_code VARCHAR(32) NOT NULL DEFAULT 'SHIPPED',
|
||||
current_status_text TEXT NULL,
|
||||
eta TIMESTAMP WITH TIME ZONE NULL,
|
||||
last_synced_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uk_platform_express_waybill UNIQUE (carrier, tracking_no)
|
||||
);
|
||||
|
||||
-- 跳过部分
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
作者:自动生成(可手动微调)
|
||||
日期:2026-02-24
|
||||
@@ -141,6 +141,7 @@
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
||||
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
|
||||
import { UserProfile } from '@/pages/user/types.uts'
|
||||
@@ -348,6 +349,35 @@ const handleLogin = async () => {
|
||||
if (uid != null) {
|
||||
uni.setStorageSync('user_id', uid)
|
||||
console.log('用户ID已保存到本地存储:', uid)
|
||||
// 获取并上报推送 CID(若可用)
|
||||
try {
|
||||
uni.getPushClientId({
|
||||
success: async (res: any) => {
|
||||
const cid = (res && (res.clientid || res.cid)) ? (res.clientid || res.cid) : (typeof res === 'string' ? res : null)
|
||||
if (cid != null && cid !== '') {
|
||||
try {
|
||||
uni.setStorageSync('uni_push2_cid', cid)
|
||||
} catch (e) {}
|
||||
try {
|
||||
await AkReq.request({
|
||||
url: '/api/v1/push/register',
|
||||
method: 'POST',
|
||||
data: { cid, platform: 'android' },
|
||||
contentType: 'application/json'
|
||||
})
|
||||
console.log('CID 已上报后台:', cid)
|
||||
} catch (e) {
|
||||
console.warn('上报 CID 失败:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('获取 Push CID 失败:', err)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('getPushClientId 调用异常:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
import { ref } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { ensureUserProfile } from '@/utils/sapi.uts'
|
||||
import { AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||
|
||||
// 响应式数据
|
||||
const email = ref<string>('')
|
||||
@@ -293,6 +294,30 @@
|
||||
console.log('ℹ️ 需要邮箱验证,验证后登录时会自动创建用户资料')
|
||||
}
|
||||
|
||||
// 获取并上报推送 CID(若可用)
|
||||
try {
|
||||
uni.getPushClientId({
|
||||
success: async (res: any) => {
|
||||
const cid = (res && (res.clientid || res.cid)) ? (res.clientid || res.cid) : (typeof res === 'string' ? res : null)
|
||||
if (cid != null && cid !== '') {
|
||||
try { uni.setStorageSync('uni_push2_cid', cid) } catch (e) {}
|
||||
try {
|
||||
await AkReq.request({
|
||||
url: '/api/v1/push/register',
|
||||
method: 'POST',
|
||||
data: { cid, platform: 'android' },
|
||||
contentType: 'application/json'
|
||||
})
|
||||
console.log('CID 已上报后台:', cid)
|
||||
} catch (e) { console.warn('上报 CID 失败:', e) }
|
||||
}
|
||||
},
|
||||
fail: (err: any) => { console.warn('获取 Push CID 失败:', err) }
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('getPushClientId 调用异常:', e)
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: '注册成功',
|
||||
icon: 'success'
|
||||
|
||||
23
server/README.md
Normal file
23
server/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Push Server (开发用)
|
||||
|
||||
这是一个用于本地开发与调试的轻量 Node.js 推送后端(mock)。
|
||||
|
||||
功能:
|
||||
- 注册/更新设备:`POST /api/v1/push/register` { cid, user_id, platform }
|
||||
- 注销设备:`POST /api/v1/push/unregister` { cid | user_id }
|
||||
- 列出设备:`GET /api/v1/push/devices?user_id=...&active=true|false`
|
||||
- 发送推送(模拟):`POST /api/v1/push/send` { cids:[], user_id, notification, payload }
|
||||
|
||||
如果你有真实的推送服务端 API,可以设置环境变量 `PUSH_PROXY_URL`(和可选的 `PUSH_PROXY_TOKEN`),服务器会将 `/api/v1/push/send` 请求代理到该 URL。
|
||||
|
||||
快速使用:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
默认监听端口:`7301`,可通过 `PORT` 环境变量修改。
|
||||
|
||||
设备存储在 `server/data/push_devices.json`,用于本地持久化。
|
||||
1
server/data/push_devices.json
Normal file
1
server/data/push_devices.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
15
server/package.json
Normal file
15
server/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "mall-push-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Local push backend for development (register devices, mock send)",
|
||||
"main": "push-server.js",
|
||||
"scripts": {
|
||||
"start": "node push-server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
}
|
||||
135
server/push-server.js
Normal file
135
server/push-server.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
const cors = require('cors')
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
const fetch = require('node-fetch')
|
||||
|
||||
const PORT = process.env.PORT || 7301
|
||||
const DATA_DIR = path.join(__dirname, 'data')
|
||||
const DEVICES_FILE = path.join(DATA_DIR, 'push_devices.json')
|
||||
|
||||
async function ensureDataDir() {
|
||||
try {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true })
|
||||
try {
|
||||
await fs.access(DEVICES_FILE)
|
||||
} catch (e) {
|
||||
await fs.writeFile(DEVICES_FILE, '[]', 'utf8')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('无法创建数据目录:', e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function readDevices() {
|
||||
try {
|
||||
const txt = await fs.readFile(DEVICES_FILE, 'utf8')
|
||||
return JSON.parse(txt || '[]')
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDevices(devices) {
|
||||
await fs.writeFile(DEVICES_FILE, JSON.stringify(devices, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async function start() {
|
||||
await ensureDataDir()
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(bodyParser.json())
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }))
|
||||
|
||||
// 注册或更新设备
|
||||
app.post('/api/v1/push/register', async (req, res) => {
|
||||
const { cid, user_id, platform } = req.body || {}
|
||||
if (!cid) return res.status(400).json({ error: 'cid required' })
|
||||
const devices = await readDevices()
|
||||
let found = devices.find(d => d.cid === cid)
|
||||
const now = new Date().toISOString()
|
||||
if (found) {
|
||||
found.user_id = user_id ?? found.user_id
|
||||
found.platform = platform ?? found.platform
|
||||
found.updated_at = now
|
||||
found.active = true
|
||||
} else {
|
||||
found = { cid, user_id: user_id ?? null, platform: platform ?? null, created_at: now, updated_at: now, active: true }
|
||||
devices.push(found)
|
||||
}
|
||||
await writeDevices(devices)
|
||||
return res.json({ ok: true, cid })
|
||||
})
|
||||
|
||||
// 注销设备(可选移除或置为 inactive)
|
||||
app.post('/api/v1/push/unregister', async (req, res) => {
|
||||
const { cid, user_id } = req.body || {}
|
||||
if (!cid && !user_id) return res.status(400).json({ error: 'cid or user_id required' })
|
||||
let devices = await readDevices()
|
||||
if (cid) {
|
||||
devices = devices.map(d => d.cid === cid ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||
} else if (user_id) {
|
||||
devices = devices.map(d => d.user_id === user_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||
}
|
||||
await writeDevices(devices)
|
||||
return res.json({ ok: true })
|
||||
})
|
||||
|
||||
// 列出设备
|
||||
app.get('/api/v1/push/devices', async (req, res) => {
|
||||
const { user_id, active } = req.query
|
||||
let devices = await readDevices()
|
||||
if (user_id) devices = devices.filter(d => String(d.user_id) === String(user_id))
|
||||
if (active != null) devices = devices.filter(d => String(!!d.active) === String(active === 'true'))
|
||||
res.json({ ok: true, total: devices.length, data: devices })
|
||||
})
|
||||
|
||||
// 发送推送(mock 或代理到真实 provider,当环境变量 PUSH_PROXY_URL 设置时会代理请求)
|
||||
app.post('/api/v1/push/send', async (req, res) => {
|
||||
const { cids, user_id, notification, payload } = req.body || {}
|
||||
if ((!cids || cids.length === 0) && !user_id) return res.status(400).json({ error: 'cids or user_id required' })
|
||||
|
||||
let targets = []
|
||||
if (cids && cids.length > 0) targets = cids
|
||||
else if (user_id) {
|
||||
const devices = await readDevices()
|
||||
targets = devices.filter(d => String(d.user_id) === String(user_id) && d.active).map(d => d.cid)
|
||||
}
|
||||
|
||||
// 如果配置了 PUSH_PROXY_URL 则转发到外部推送服务(例如 uni-push2 的服务端 API)
|
||||
const proxyUrl = process.env.PUSH_PROXY_URL || ''
|
||||
const proxyToken = process.env.PUSH_PROXY_TOKEN || ''
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const resp = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, proxyToken ? { Authorization: `Bearer ${proxyToken}` } : {}),
|
||||
body: JSON.stringify({ targets, notification, payload })
|
||||
})
|
||||
const data = await resp.text()
|
||||
return res.json({ ok: true, proxied: true, status: resp.status, response: data })
|
||||
} catch (e) {
|
||||
console.warn('代理推送失败:', e)
|
||||
return res.status(500).json({ ok: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// 否则仅记录并返回模拟结果
|
||||
console.log('Mock push to', targets.length, 'clients')
|
||||
console.log('notification:', notification)
|
||||
console.log('payload:', payload)
|
||||
return res.json({ ok: true, mocked: true, sent: targets.length })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Push server listening on http://localhost:${PORT}`)
|
||||
})
|
||||
}
|
||||
|
||||
start().catch(e => {
|
||||
console.error('启动失败:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user