Merge remote-tracking branch 'origin/ysj-delivery'
This commit is contained in:
59
.github/workflows/deploy-cloudfunc.yml
vendored
Normal file
59
.github/workflows/deploy-cloudfunc.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Deploy CloudFunction (pack & optional upload)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install minimal deps for pack script
|
||||||
|
run: |
|
||||||
|
cd server
|
||||||
|
npm install archiver node-fetch form-data
|
||||||
|
|
||||||
|
- name: Pack & Upload CloudFunction
|
||||||
|
env:
|
||||||
|
CLOUD_UPLOAD_URL: ${{ secrets.CLOUD_UPLOAD_URL }}
|
||||||
|
CLOUD_UPLOAD_TOKEN: ${{ secrets.CLOUD_UPLOAD_TOKEN }}
|
||||||
|
UNI_PUSH_APPID: ${{ secrets.UNI_PUSH_APPID }}
|
||||||
|
run: |
|
||||||
|
node server/tools/deploy-cloudfunc.js --upload
|
||||||
|
|
||||||
|
- name: Trigger Deploy API (optional)
|
||||||
|
if: ${{ secrets.CLOUD_DEPLOY_API != '' }}
|
||||||
|
env:
|
||||||
|
CLOUD_DEPLOY_API: ${{ secrets.CLOUD_DEPLOY_API }}
|
||||||
|
CLOUD_DEPLOY_TOKEN: ${{ secrets.CLOUD_DEPLOY_TOKEN }}
|
||||||
|
UPLOAD_URL: ${{ secrets.CLOUD_UPLOAD_URL }}
|
||||||
|
run: |
|
||||||
|
echo "Triggering deploy API: $CLOUD_DEPLOY_API"
|
||||||
|
# POST deploy trigger with basic info; adapt fields to your provider API
|
||||||
|
curl -sS -X POST "$CLOUD_DEPLOY_API" \
|
||||||
|
-H "Authorization: Bearer $CLOUD_DEPLOY_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{ \"uploadUrl\": \"$UPLOAD_URL\" }" \
|
||||||
|
|| echo "deploy trigger returned non-zero status"
|
||||||
|
|
||||||
|
- name: Invoke Cloud Function (test - optional)
|
||||||
|
if: ${{ secrets.CLOUD_FUNC_URL != '' }}
|
||||||
|
env:
|
||||||
|
CLOUD_FUNC_URL: ${{ secrets.CLOUD_FUNC_URL }}
|
||||||
|
PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }}
|
||||||
|
TEST_DEVICE_CID: ${{ secrets.TEST_DEVICE_CID }}
|
||||||
|
run: |
|
||||||
|
echo "Invoking cloud function for smoke test"
|
||||||
|
curl -sS -X POST "$CLOUD_FUNC_URL" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{ \"token\": \"$PUSH_TOKEN\", \"push_clientid\": \"$TEST_DEVICE_CID\", \"title\": \"CI Test\", \"content\": \"hello from CI\", \"payload\": {} }" \
|
||||||
|
|| echo "invoke returned non-zero"
|
||||||
@@ -4,6 +4,19 @@
|
|||||||
{
|
{
|
||||||
"playground" : "standard",
|
"playground" : "standard",
|
||||||
"type" : "uni-app:app-android"
|
"type" : "uni-app:app-android"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"app" : {
|
||||||
|
"launchtype" : "remote"
|
||||||
|
},
|
||||||
|
"default" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"h5" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"provider" : "alipay",
|
||||||
|
"type" : "uniCloud"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
172
BACKEND_MIGRATION_PLAN.md
Normal file
172
BACKEND_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# 后端收敛与防护设计(最小可落地方案)
|
||||||
|
|
||||||
|
说明:本文档针对当前仓库中前端直接写 Supabase(存在风险:前端使用 service_role key)问题,提供可执行的最小后台设计、接口与 DB 改造清单,供开发/运维快速落地。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目标(Why)
|
||||||
|
- 阻断前端持有 `service_role` 导致的越权写库风险。
|
||||||
|
- 将关键业务写入(接单/订单状态/资金/库存等)收敛到可信后端,保证原子性、幂等和审计。
|
||||||
|
- 在数据库侧加入最后一道防线(约束 + RLS + RPC)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要执行步骤(What / High level)
|
||||||
|
1. 立刻移除前端的 `service_role`,前端只用 `anon`(阻断最大风险)。
|
||||||
|
2. 实现最小后台 API(订单管理、核心状态流转 rpc + HTTP 接口)。
|
||||||
|
3. 在数据库添加约束、RLS 策略与 RPC(把关键状态流转做成原子函数)。
|
||||||
|
4. 前端分阶段切换到新 API 并回归测试。
|
||||||
|
5. 补充审计、幂等与监控,逐步迁移其它敏感写入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细步骤(可直接执行)
|
||||||
|
|
||||||
|
### A. 紧急措施(立即)
|
||||||
|
- 编辑 `ak/config.uts`:注释或删除明文 `SUPA_KEY`(service_role),替为 anon 或从构建环境注入。
|
||||||
|
- 确认前端不再把 service_role 打包发布(CI/构建流水线更新)。
|
||||||
|
|
||||||
|
### B. 最小后端 API(优先交付)
|
||||||
|
在 `server/` 下新增 `server/routes/delivery.js`(或 Fastify 插件)。
|
||||||
|
|
||||||
|
推荐接口(统一返回 `{ ok, code?, message?, data? }`):
|
||||||
|
- POST /api/v1/delivery/accept-task
|
||||||
|
- body: { task_id, action_id?: string }
|
||||||
|
- auth: Bearer user_jwt
|
||||||
|
- 后端流程:验证 token → 验证司机权限 → 调用 RPC `rpc_accept_task(uid, task_id, action_id)` → 返回 task 新状态
|
||||||
|
- POST /api/v1/delivery/update-status
|
||||||
|
- body: { task_id, new_status, action_id, metadata? }
|
||||||
|
- 后端流程:验证 → 调用 `rpc_update_delivery_status(...)`(RPC 内部做多表事务)
|
||||||
|
- POST /api/v1/notifications/express/create
|
||||||
|
- body: notification payload + message_id/action_id(幂等)
|
||||||
|
- 后端流程:幂等检查 -> 插入 -> 触发 push(内部队列)
|
||||||
|
|
||||||
|
后端实现要点:
|
||||||
|
- 鉴权:解析并验证前端 Supabase JWT,取 `uid` 作为操作人。
|
||||||
|
- 内部调用:后端使用环境变量 `SERVICE_ROLE_KEY` 或直接调用 RPC(推荐)执行受权写入。
|
||||||
|
- 返回格式统一并带错误码,便于前端处理与监控。
|
||||||
|
|
||||||
|
### C. Postgres RPC(示例 SQL)
|
||||||
|
下面为 `rpc_accept_task` 草稿,供 DB 管理员在 Dev 环境调整与测试:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- rpc_accept_task: driver 领取任务(幂等 + 原子)
|
||||||
|
CREATE OR REPLACE FUNCTION public.rpc_accept_task(p_driver_uuid uuid, p_task_uuid uuid, p_action_id text)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_task RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- 幂等:插入到去重表,若已存在则返回已处理结果
|
||||||
|
INSERT INTO action_dedupe(action_id, created_at)
|
||||||
|
VALUES (p_action_id, now())
|
||||||
|
ON CONFLICT (action_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 执行原子更新
|
||||||
|
WITH u AS (
|
||||||
|
UPDATE ml_orders
|
||||||
|
SET status = 2, updated_at = now()
|
||||||
|
WHERE id = p_task_uuid AND status = 1
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
SELECT * INTO v_task FROM u LIMIT 1;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'message', 'task not available');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 写审计
|
||||||
|
INSERT INTO audit_logs(actor_id, action, target_table, target_id, payload, created_at)
|
||||||
|
VALUES (p_driver_uuid, 'accept_task', 'ml_orders', p_task_uuid, row_to_json(v_task), now());
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('ok', true, 'task', to_jsonb(v_task));
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 使用 `SECURITY DEFINER` 并确保函数拥有适当权限(仅 server/service_role 可调用)。
|
||||||
|
- `action_dedupe` 表需创建并设 unique(action_id)。
|
||||||
|
|
||||||
|
### D. RLS 与 约束(示例)
|
||||||
|
- 开启 RLS,例如对 `user_addresses`:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_addresses ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY user_owns_address ON user_addresses
|
||||||
|
FOR ALL
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
- 对关键表(如 `ml_orders`):
|
||||||
|
- 禁止直接由匿名或普通前端更新关键列;只允许 `rpc` / service 调用通过函数更新。
|
||||||
|
- 索引/约束:
|
||||||
|
- message_id 唯一:`CREATE UNIQUE INDEX ux_express_notifications_message_id ON express_notifications(message_id);`
|
||||||
|
- 补全 NOT NULL / FK / CHECK(枚举字段限制等)。
|
||||||
|
|
||||||
|
### E. 鉴权实现要点(后端)
|
||||||
|
- 验证前端 JWT:解析/校验 Supabase JWT(可使用 Supabase 的用户 API 或直接 JWT 验证)。从 token 获取 `uid` 作为操作人。
|
||||||
|
- 后端自身使用环境变量 `SERVICE_ROLE_KEY` 调用 Supabase Admin API 或直接调用 Postgres RPC(建议后者更原子)。
|
||||||
|
- 不要在后端把 `service_role` 返回或写进前端文件。
|
||||||
|
|
||||||
|
### F. 幂等与去重
|
||||||
|
- 对所有会被重复调用的外部动作(webhook、client retry)要求 `action_id`/`message_id`,并在 DB 层做 `ON CONFLICT DO NOTHING` 或在 RPC 先检查 `action_dedupe`。
|
||||||
|
|
||||||
|
### G. 审计与监控
|
||||||
|
- 新建 `audit_logs(actor_id, action, target_table, target_id, payload, created_at)` 并在 RPC 中写入。
|
||||||
|
- 错误报警(Sentry 或日志轮询),并在关键接口记录 metrics(错误率、latency、冲突数)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移与上线路线(分阶段)
|
||||||
|
1. Dev:实现 RPC + API,更新 dev 配置把前端用 anon,完成单元测试与集成测试。
|
||||||
|
2. Stage:灰度发布后端 API,部分司机/少量流量切换到 API。监控 48h。
|
||||||
|
3. Prod:全面切换,删除前端中的直写调用(或通过 feature-flag 关闭)。
|
||||||
|
4. 回滚:保留旧直写代码并用 feature flag 随时回退;若回退需同时短期恢复前端原 key(仅极端应急,注意风险并在短时间内移除)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最小交付清单(可追踪任务)
|
||||||
|
- [ ] 注释/移除前端 `service_role`:`ak/config.uts`(紧急)
|
||||||
|
- [ ] 在 `server/` 新增 `POST /api/v1/delivery/accept-task`(鉴权 + 调用 RPC)
|
||||||
|
- [ ] 在 DB 创建 `rpc_accept_task`、`action_dedupe`、`audit_logs` 表
|
||||||
|
- [ ] 对 `user_addresses`、`ml_orders` 等启用 RLS 策略(逐表)
|
||||||
|
- [ ] 前端将接单/确认调用迁移到新 API(feature-flag)并回归测试
|
||||||
|
- [ ] 部署后监控并逐步扩大灰度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
- 风险:短期内若不移除 `service_role`,即便实现后台 API,也无法阻止攻击者通过已有 key 直接写库。优先级最高。
|
||||||
|
- 注意隐私/合规:审计日志要避免写入敏感明文(例如完整支付凭证)或加密存储。
|
||||||
|
- DB 权限:确保 RPC 使用受限角色执行并限制 RPC 所能做的动作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐下一步(我可以马上帮你做)
|
||||||
|
- 选项 1:生成并提交 `ak/config.uts` 的 patch,将 `SUPA_KEY` 替换为示例 anon(阻断风险)。
|
||||||
|
- 选项 2:生成 `rpc_accept_task` 的完整 SQL + `server/routes/delivery.js` 的 Node 实现样例(包含鉴权中间件与调用示例)。
|
||||||
|
- 选项 3:列出仓库内所有前端写入点并按“必须走后端 / 可保留直写”分级清单(便于逐步迁移)。
|
||||||
|
|
||||||
|
请回复 `1`、`2` 或 `3` 选项,或告诉我需要调整的文档内容/格式。
|
||||||
|
|
||||||
|
---
|
||||||
|
## 附录:前端不得直接连接数据库(简明实施指南)
|
||||||
|
|
||||||
|
结论:所有需要提升权限、跨表原子性、审计或幂等保证的写入必须走可信后端;仅在严格受限(RLS + 约束)且只影响用户自身资源时,前端可用 anon key 直写。
|
||||||
|
|
||||||
|
- 何时必须走后端:更新 `driver_id`、订单状态、资金/结算、库存变更、跨表事务、需要审计或幂等的操作、需使用 `service_role` 权限的写入。
|
||||||
|
- 何时可允许前端直写:仅用户自身数据(例如 `user_addresses`)且已启用 RLS 与完整约束,且不涉及跨表或审计要求。
|
||||||
|
|
||||||
|
最小后端职责(示例):
|
||||||
|
- 验证前端 JWT(取 `uid`);做权限校验与幂等检查(`action_id`)。
|
||||||
|
- 调用 Postgres RPC(`SECURITY DEFINER`)或使用 `SERVICE_ROLE_KEY` 完成受权写入。
|
||||||
|
- 写入 `audit_logs` 并返回统一错误格式 `{ ok, code?, message?, data? }`。
|
||||||
|
|
||||||
|
紧急建议(复述以便执行):
|
||||||
|
- 立即移除或注释前端的 `service_role`(已在工作区修改 `ak/config.uts`)。
|
||||||
|
- 在 CI 中阻止带有 `service_role` 的 key 进入前端配置或打包产物。
|
||||||
|
- 为关键流实现 RPC(如 `rpc_accept_task`)并暴露最小后端 API(如 `/api/v1/delivery/accept-task`),逐步将前端写入迁移到后端。
|
||||||
|
|
||||||
|
需要我把这个附录再整理为单独文件或把 `rpc_accept_task` SQL 与 `server/routes/delivery.js` 示例直接追加到本文件吗?
|
||||||
@@ -45,7 +45,7 @@ Generated from root pages.json (pages + subPackages).
|
|||||||
- pages/mall/consumer/subscription/subscribe-checkout
|
- pages/mall/consumer/subscription/subscribe-checkout
|
||||||
- pages/mall/consumer/subscription/my-subscriptions
|
- pages/mall/consumer/subscription/my-subscriptions
|
||||||
|
|
||||||
### pages/mall/delivery
|
<!-- ### pages/mall/delivery
|
||||||
- pages/mall/delivery/index
|
- pages/mall/delivery/index
|
||||||
- pages/mall/delivery/order-detail
|
- pages/mall/delivery/order-detail
|
||||||
- pages/mall/delivery/profile
|
- pages/mall/delivery/profile
|
||||||
@@ -58,7 +58,7 @@ Generated from root pages.json (pages + subPackages).
|
|||||||
- pages/mall/delivery/vehicle
|
- pages/mall/delivery/vehicle
|
||||||
- pages/mall/delivery/vehicle-add
|
- pages/mall/delivery/vehicle-add
|
||||||
- pages/mall/delivery/vehicle-edit
|
- pages/mall/delivery/vehicle-edit
|
||||||
- pages/mall/delivery/settings
|
- pages/mall/delivery/settings -->
|
||||||
|
|
||||||
### pages/mall/analytics
|
### pages/mall/analytics
|
||||||
- pages/mall/analytics/index
|
- pages/mall/analytics/index
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 🛍️ Mall (uni-app / uvue)
|
# 🛍️ Mall (uni-app / uvue)
|
||||||
|
|
||||||
本仓库为 uni-app(uvue/uts)商城项目,包含消费者端、配送端、数据分析、管理后台、商家端、客服端等模块。
|
本仓库为 uni-app(uvue/uts)商城项目,包含消费者端、数据分析、管理后台、商家端、客服端等模块(注:旧有独立配送端已弃用,现改为第三方物流对接)。
|
||||||
|
|
||||||
## 📦 路由与分包
|
## 📦 路由与分包
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ pages/
|
|||||||
user/ # 登录/注册/用户中心等公共页面
|
user/ # 登录/注册/用户中心等公共页面
|
||||||
mall/
|
mall/
|
||||||
consumer/ # 消费者端
|
consumer/ # 消费者端
|
||||||
delivery/ # 配送端
|
|
||||||
analytics/ # 数据分析
|
analytics/ # 数据分析
|
||||||
admin/ # 管理后台
|
admin/ # 管理后台
|
||||||
merchant/ # 商家端
|
merchant/ # 商家端
|
||||||
@@ -54,3 +53,12 @@ ak/
|
|||||||
|
|
||||||
- 请避免提交本地环境相关配置(如 `ak/config.uts`)到仓库(已通过 `.gitignore` 处理)
|
- 请避免提交本地环境相关配置(如 `ak/config.uts`)到仓库(已通过 `.gitignore` 处理)
|
||||||
- 分包页面路径变更请同步更新 `pages.json`
|
- 分包页面路径变更请同步更新 `pages.json`
|
||||||
|
|
||||||
|
## 📣 配送模块 — 推送与设备(整合说明)
|
||||||
|
|
||||||
|
项目中 `pages/mall/delivery/doc/需求文档` 目录包含多份关于配送、轨迹与推送的需求与建表示例。为了便于 DBA/后端/前端对齐,已将核心要点整合为草稿:
|
||||||
|
|
||||||
|
- 推送建表迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`
|
||||||
|
- 推送完整附录(含平台侧建表示例):`pages/mall/delivery/doc/需求文档/推送与设备需求文档_含建表附录.md`
|
||||||
|
|
||||||
|
建议下一步:在测试库执行迁移并验证 `notify_queue` / 后台消费者流程。若需要,我可以生成迁移运行命令或提供后台消费者示例代码。
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
//自己的配置自己解开即可
|
//自己的配置自己解开即可
|
||||||
//export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
//export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
||||||
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
||||||
//export const SUPA_URL: string = 'http://192.168.1.62:18000'
|
export const SUPA_URL: string = 'http://192.168.1.62:18000'
|
||||||
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzczNjIxMTQzLCJleHAiOjE5MzEzMDExNDN9.gkYe875_vsdcdsKbhOTwwe2klkuMNj_UY45aq4zwuy0'
|
||||||
export const SUPA_URL: string = 'http://192.168.1.63:18000'
|
// export const SUPA_URL: string = 'http://192.168.1.63:18000'
|
||||||
export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
|
// export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
|
||||||
|
|
||||||
// WebSocket 实时连接(内网使用 ws:// 而非 wss://)
|
// WebSocket 实时连接(内网使用 ws:// 而非 wss://)
|
||||||
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
|
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
|
||||||
//export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
|
export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
|
||||||
export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
|
// export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
|
||||||
|
|
||||||
|
// 推送服务地址(用于本地调试,可改为 http://<your-ip>:7301)
|
||||||
|
export const PUSH_SERVER_URL: string = 'http://192.168.1.62:7301'
|
||||||
|
|
||||||
// 备用配置(已注释,如需切换可取消注释)
|
// 备用配置(已注释,如需切换可取消注释)
|
||||||
// 开发环境 - 其他内网地址
|
// 开发环境 - 其他内网地址
|
||||||
|
|||||||
162
manifest.json
162
manifest.json
@@ -1,74 +1,92 @@
|
|||||||
{
|
{
|
||||||
"name": "mall",
|
"name": "mall",
|
||||||
"appid": "__UNI__YOUR_APP_ID__",
|
"appid": "__UNI__9462CA7",
|
||||||
"description": "A multi-role e-commerce application.",
|
"description": "A multi-role e-commerce application.",
|
||||||
"versionName": "1.0.0",
|
"versionName": "1.0.0",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"app-plus": {
|
"app-plus": {
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
"nvueStyleCompiler": "uni-app",
|
"nvueStyleCompiler": "uni-app",
|
||||||
"compilerVersion": 3,
|
"compilerVersion": 3,
|
||||||
"splashscreen": {
|
"splashscreen": {
|
||||||
"alwaysShowBeforeRender": true,
|
"alwaysShowBeforeRender": true,
|
||||||
"waiting": true,
|
"waiting": true,
|
||||||
"autoclose": true,
|
"autoclose": true,
|
||||||
"delay": 0
|
"delay": 0
|
||||||
},
|
},
|
||||||
"modules": {},
|
"modules": {},
|
||||||
"distribute": {
|
"distribute": {
|
||||||
"android": {
|
"android": {
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ios": {},
|
"ios": {},
|
||||||
"sdkConfigs": {}
|
"sdkConfigs": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quickapp": {},
|
"quickapp": {},
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "",
|
"appid": "",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false
|
"urlCheck": false
|
||||||
},
|
},
|
||||||
"usingComponents": true
|
"usingComponents": true,
|
||||||
},
|
"unipush": {
|
||||||
"mp-alipay": {
|
"enable": false
|
||||||
"usingComponents": true
|
}
|
||||||
},
|
},
|
||||||
"mp-baidu": {
|
"mp-alipay": {
|
||||||
"usingComponents": true
|
"usingComponents": true
|
||||||
},
|
},
|
||||||
"mp-toutiao": {
|
"mp-baidu": {
|
||||||
"usingComponents": true
|
"usingComponents": true
|
||||||
},
|
},
|
||||||
"uniStatistics": {
|
"mp-toutiao": {
|
||||||
"enable": false
|
"usingComponents": true
|
||||||
},
|
},
|
||||||
"vueVersion": "3",
|
"uniStatistics": {
|
||||||
"uni-app-x": {},
|
"enable": false
|
||||||
"h5": {
|
},
|
||||||
"title": "mall",
|
"vueVersion": "3",
|
||||||
"router": {
|
"uni-app-x": {},
|
||||||
"mode": "hash",
|
"h5": {
|
||||||
"base": "./"
|
"title": "mall",
|
||||||
}
|
"router": {
|
||||||
}
|
"mode": "hash",
|
||||||
|
"base": "./"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"router": {
|
||||||
|
"mode": ""
|
||||||
|
},
|
||||||
|
"unipush": {
|
||||||
|
"enable": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app-android": {
|
||||||
|
"distribute": {
|
||||||
|
"modules": {
|
||||||
|
"uni-push": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
1182
package-lock.json
generated
1182
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"lint:fix": "eslint --ext .js,.vue,.uvue --fix pages layouts"
|
"lint:fix": "eslint --ext .js,.vue,.uvue --fix pages layouts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^6.0.0"
|
"@supabase/supabase-js": "^2.99.1",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"express": "^5.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dcloudio/types": "^3.4.29",
|
"@dcloudio/types": "^3.4.29",
|
||||||
|
|||||||
77
pages.json
77
pages.json
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
|
||||||
"path": "pages/mall/admin/homePage/index",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "管理后台",
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/user/login",
|
"path": "pages/user/login",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -275,6 +268,13 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "all",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "待接取任务",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "earnings",
|
"path": "earnings",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -365,6 +365,69 @@
|
|||||||
"navigationBarTitleText": "设置",
|
"navigationBarTitleText": "设置",
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "配送模块测试索引",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/merchant-order-list",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "商家发货管理",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/merchant-order-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "订单物流详情",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/platform-tracking-query",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "轨迹排障查询",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/platform-webhook-logs",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "Webhook日志",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/api-simulator",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "第三方接口模拟器",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/platform-config-center",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "配送配置中心",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/consumer-logistics-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "用户查快递",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/consumer-order-list",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的快递",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -245,3 +245,23 @@ uni-app-x 项目结构
|
|||||||
- **数据管理**: 支持移除已选优惠券标签。
|
- **数据管理**: 支持移除已选优惠券标签。
|
||||||
|
|
||||||
- **路由映射**: 已在 `adminComponentMap.uts` 中注册 `MarketingNewcomerGift` 组件。
|
- **路由映射**: 已在 `adminComponentMap.uts` 中注册 `MarketingNewcomerGift` 组件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026年2月3日 修复追加
|
||||||
|
|
||||||
|
### 现象一:登录后不跳转或直接进入管理端
|
||||||
|
- **问题**: 用户报告登录成功后无反应,或者刷新后由于 `IS_TEST_MODE` 开启,系统停留在默认的 `pages/mall/admin/homePage/index`。
|
||||||
|
- **修复**: 修改 `pages/user/login.uvue`,强制在登录成功后跳转到商城首页 `/pages/mall/consumer/index`,确保引导流程正确。
|
||||||
|
|
||||||
|
### 现象二:数据分析页面编译错误
|
||||||
|
- **问题**: `pages/mall/analytics/profile.uvue` 报错 `Unexpected token`。
|
||||||
|
- **修复**: 补全了代码中缺失的闭合花括号 `}`。
|
||||||
|
|
||||||
|
### 现象三:H5 刷新后登录态/数据丢失
|
||||||
|
- **问题**: 刷新页面导致内存状态清空,Supabase Session 回复慢导致用户 ID 丢失,页面显示“今日数据”全为 0。
|
||||||
|
- **修复**:
|
||||||
|
- 在 `utils/store.uts` 中通过 `uni.setStorageSync` 实现 `user_id` 的本地持久化。
|
||||||
|
- 增强 `getCurrentUserId` 逻辑,支持本地存储兜底。
|
||||||
|
- 优化 `admin/admin` 模拟账号的持久化支持。
|
||||||
|
|
||||||
|
|||||||
@@ -1014,7 +1014,6 @@ function goToFeedback() {
|
|||||||
color: #8c929b;
|
color: #8c929b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.trend-legend {
|
.trend-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
-- - 完成配送:ml_delivery_tasks.status = 5
|
-- - 完成配送:ml_delivery_tasks.status = 5
|
||||||
-- - 配送时长:delivered_at - assigned_at(分钟)
|
-- - 配送时长:delivered_at - assigned_at(分钟)
|
||||||
-- - avg_fee:delivery_fee 平均
|
-- - avg_fee:delivery_fee 平均
|
||||||
|
|
||||||
|
-- 弃用
|
||||||
-- =====================================================================================
|
-- =====================================================================================
|
||||||
|
|
||||||
-- 1) 配送效率日趋势
|
-- 1) 配送效率日趋势
|
||||||
|
|||||||
360
pages/mall/delivery/all.uvue
Normal file
360
pages/mall/delivery/all.uvue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<view class="all-orders-container">
|
||||||
|
<!-- 头部标题栏 -->
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="back-btn" @click="goBack">
|
||||||
|
<text class="back-icon">‹</text>
|
||||||
|
<text class="back-text">返回</text>
|
||||||
|
</view>
|
||||||
|
<text class="page-title">全部待接订单</text>
|
||||||
|
<view class="refresh-action" @click="loadOrders">
|
||||||
|
<text class="refresh-icon">🔄</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 订单列表区 -->
|
||||||
|
<scroll-view class="order-list-scroll" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="onRefresh">
|
||||||
|
|
||||||
|
<view v-if="orders.length === 0" class="empty-state">
|
||||||
|
<image src="/static/images/no-order.png" class="empty-img" mode="aspectFit" />
|
||||||
|
<text class="empty-text">附近暂时没有待接订单</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="order in orders" :key="order.id" class="order-card">
|
||||||
|
<view class="order-fee-center">
|
||||||
|
<text class="order-fee-text">¥{{ order.delivery_fee }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-route-vertical">
|
||||||
|
<view class="route-point">
|
||||||
|
<text class="route-icon-pink">📍</text>
|
||||||
|
<text class="route-text-main">{{ order.pickup_address.detail || order.pickup_address.area }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="route-arrow-down">↓</text>
|
||||||
|
<view class="route-point">
|
||||||
|
<text class="route-icon-home">🏠</text>
|
||||||
|
<text class="route-text-main">{{ order.delivery_address.detail || order.delivery_address.area }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-meta-info">
|
||||||
|
<text class="meta-label">距离: {{ order.distance }}km</text>
|
||||||
|
<text class="meta-label">预计: {{ order.estimated_time }}分钟</text>
|
||||||
|
<text class="meta-label">下单: {{ order.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-actions-stack">
|
||||||
|
<button class="order-btn-full accept" @click="acceptOrder(order.id)">接受订单</button>
|
||||||
|
<button class="order-btn-full detail" @click="viewOrderDetail(order.id)">查看详情</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="orders.length > 0" class="list-bottom">
|
||||||
|
<text class="bottom-text">已加载全部订单</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="uts">
|
||||||
|
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||||
|
import { getCurrentUserId } from '@/utils/store.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orders: [] as any[],
|
||||||
|
isRefreshing: false,
|
||||||
|
driverId: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onLoad() {
|
||||||
|
await this.getDriverId()
|
||||||
|
await this.loadOrders()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async getDriverId() {
|
||||||
|
try {
|
||||||
|
await supaReady
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
if (!userId) return
|
||||||
|
const res = await supa.from('ml_delivery_drivers').select('id').eq('user_id', userId).limit(1).execute()
|
||||||
|
if (res && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
this.driverId = res.data[0].id
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getDriverId error', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadOrders() {
|
||||||
|
this.isRefreshing = true
|
||||||
|
try {
|
||||||
|
await supaReady
|
||||||
|
const res = await supa.from('ml_delivery_tasks')
|
||||||
|
.select('*')
|
||||||
|
.is('driver_id', 'null')
|
||||||
|
.eq('status', 1)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (res && Array.isArray(res.data)) {
|
||||||
|
this.orders = res.data.map((r: any) => this._transformTask(r))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadAllOrders error', e)
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRefresh() {
|
||||||
|
this.loadOrders()
|
||||||
|
},
|
||||||
|
viewOrderDetail(orderId: string) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/mall/delivery/order-detail?id=${orderId}&status=1`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
_transformTask(task: any) {
|
||||||
|
const parseAddress = (a: any) => {
|
||||||
|
if (!a) return { detail: '', area: '' }
|
||||||
|
let obj = a
|
||||||
|
if (typeof a === 'string') {
|
||||||
|
try { obj = JSON.parse(a) } catch (e) { obj = { detail: a } }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: obj.detail || obj.address || '',
|
||||||
|
area: obj.area || obj.district || obj.city || '未知区域'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
order_no: task.order_no || '无编号',
|
||||||
|
delivery_fee: Number(task.delivery_fee) || 0,
|
||||||
|
pickup_address: parseAddress(task.pickup_address),
|
||||||
|
delivery_address: parseAddress(task.delivery_address),
|
||||||
|
distance: Number(task.distance) || 0,
|
||||||
|
estimated_time: Number(task.estimated_time) || 0,
|
||||||
|
created_at: task.created_at
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async acceptOrder(taskId: string) {
|
||||||
|
if (!this.driverId) {
|
||||||
|
uni.showToast({ title: '未找到配送员身份', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showLoading({ title: '正抢单中...' })
|
||||||
|
try {
|
||||||
|
// 抢单逻辑:更新 driver_id 和状态
|
||||||
|
const res = await supa.from('ml_delivery_tasks')
|
||||||
|
.update({ driver_id: this.driverId, status: 2 })
|
||||||
|
.eq('id', taskId)
|
||||||
|
.is('driver_id', 'null') // 并发保护
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (res && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
// 同步订单状态
|
||||||
|
const orderId = (res.data[0] as any).order_id
|
||||||
|
if (orderId) {
|
||||||
|
await supa.from('ml_orders').update({ order_status: 2 }).eq('id', orderId).execute()
|
||||||
|
}
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '接单成功!', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.redirectTo({ url: '/pages/mall/delivery/index' })
|
||||||
|
}, 1500)
|
||||||
|
} else {
|
||||||
|
throw new Error('订单已被抢走')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '抢单失败,可能已被其他配送员接取', icon: 'none' })
|
||||||
|
this.loadOrders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.all-orders-container {
|
||||||
|
background-color: #f7f8fa;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1rpx solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
margin-left: 5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-action {
|
||||||
|
padding: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 20rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-fee-center {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
border-bottom: 1rpx dashed #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-fee-text {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4CAF50;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-route-vertical {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-point {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-icon-pink {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-icon-home {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-text-main {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-arrow-down {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin: 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-meta-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-actions-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn-full {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn-full.accept {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn-full.detail {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding-top: 200rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-img {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-bottom {
|
||||||
|
padding: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🚚 配送端模块项目结构说明(`/pages/mall/delivery/`)
|
|
||||||
|
|
||||||
> 本模块为配送员使用的移动端应用核心功能区,涵盖个人中心、任务管理、收入统计、车辆管理等核心功能。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 目录结构概览
|
|
||||||
|
|
||||||
```
|
|
||||||
delivery/
|
|
||||||
├── index.uvue # 配送端首页(工作台)
|
|
||||||
├── profile.uvue # 个人中心
|
|
||||||
├── profile-edit.uvue # 编辑个人信息
|
|
||||||
├── settings.uvue # 设置页面
|
|
||||||
├── tasks.uvue # 配送任务列表(按状态筛选)
|
|
||||||
├── task-detail.uvue # 单个任务详情页
|
|
||||||
├── order-history.uvue # 历史订单列表
|
|
||||||
├── order-detail.uvue # 订单详情页(从历史或当前跳转)
|
|
||||||
├── earnings.uvue # 收入明细
|
|
||||||
├── ratings.uvue # 评价记录
|
|
||||||
├── vehicle.uvue # 车辆管理主页
|
|
||||||
├── vehicle-add.uvue # 添加车辆
|
|
||||||
├── vehicle-edit.uvue # 编辑车辆信息
|
|
||||||
└── index.uvue # 首页(与上面重复?应统一命名)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 💡 注:`index.uvue` 出现两次,建议保留一个作为首页入口,另一个可重命名为 `dashboard.uvue` 或 `home.uvue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧭 各页面功能说明
|
|
||||||
|
|
||||||
### 1. `index.uvue` —— **配送端首页 / 工作台**
|
|
||||||
- ✅ 核心入口页面
|
|
||||||
- 📍 展示当前任务、今日数据、快捷操作入口
|
|
||||||
- 🔄 可跳转到“个人中心”、“任务列表”、“收入明细”等
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `profile.uvue` —— **个人中心**
|
|
||||||
- 👤 显示配送员基本信息(头像、姓名、评分、总单数)
|
|
||||||
- 📊 展示今日配送数据(完成单数、收入、里程、准时率)
|
|
||||||
- 📈 收入统计图表(最近7天)
|
|
||||||
- 🚗 功能菜单:收入明细、车辆管理、评价记录、帮助中心、意见反馈
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `profile-edit.uvue` —— **编辑个人信息**
|
|
||||||
- 🖋️ 修改头像、姓名、身份证号、驾驶证、车辆信息、服务区域等
|
|
||||||
- 📱 界面包含表单输入 + 保存按钮
|
|
||||||
- ⬅️ 左上角返回按钮(箭头+文字垂直排列)
|
|
||||||
- 💾 数据本地模拟或调用API更新
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `settings.uvue` —— **设置页面**
|
|
||||||
- ⚙️ 通用设置项(如通知、隐私、退出登录等)
|
|
||||||
- 🔐 安全相关设置(修改密码、绑定手机等)
|
|
||||||
- 📲 通常由 `profile.uvue` 中的“⚙️”图标进入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `tasks.uvue` —— **配送任务列表**
|
|
||||||
- 📋 按状态分类展示任务:
|
|
||||||
- 全部任务
|
|
||||||
- 待接单
|
|
||||||
- 配送中
|
|
||||||
- 已完成
|
|
||||||
- 🚀 点击任一任务 → 跳转至 `task-detail.uvue`
|
|
||||||
- 📈 页面顶部有“工作状态”切换开关(工作中/休息中)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `task-detail.uvue` —— **任务详情页**
|
|
||||||
- 📍 显示取货地址、送达地址、距离、预计时间
|
|
||||||
- 📞 “联系客户”按钮
|
|
||||||
- 📝 查看任务详情(可选)
|
|
||||||
- ✅ 适用于“当前任务”或“待接单”的操作场景
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. `order-history.uvue` —— **历史订单列表**
|
|
||||||
- 📜 展示已完成、已接受、配送中的历史订单
|
|
||||||
- 📌 包含订单号、状态、取送货地址、配送费、距离、时间
|
|
||||||
- 🔍 点击“查看详情” → 跳转至 `order-detail.uvue`,并携带参数 `?from=history`
|
|
||||||
- 📅 支持查看“已完成”的订单(仅显示“联系客服”按钮)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. `order-detail.uvue` —— **订单详情页(多来源)**
|
|
||||||
- 🔄 从 `tasks.uvue` 或 `order-history.uvue` 进入
|
|
||||||
- 🎯 **关键逻辑**:
|
|
||||||
- 若来自历史订单(`from=history`)且状态为“已完成” → 只显示“联系客服”
|
|
||||||
- 若来自历史订单且状态为“进行中” → 显示“接受/拒绝/导航/完成”等操作按钮
|
|
||||||
- 若非历史来源 → 显示完整操作按钮
|
|
||||||
- 📞 包含联系顾客、联系商家、联系客服三个联系方式
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. `earnings.uvue` —— **收入明细**
|
|
||||||
- 💰 展示总收入、用户打赏、商家打赏、总订单数
|
|
||||||
- 📊 按订单聚合的收入数据列表
|
|
||||||
- 📈 图表展示最近7天收入趋势
|
|
||||||
- ➕ 加载更多按钮
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. `ratings.uvue` —— **评价记录**
|
|
||||||
- ⭐ 展示用户对配送员的评价
|
|
||||||
- 📝 包含评分、评价内容、订单号、时间
|
|
||||||
- 📊 统计平均分、好评率等
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. `vehicle.uvue` —— **车辆管理主页**
|
|
||||||
- 🚗 列出当前绑定的所有车辆
|
|
||||||
- ➕ “添加车辆”按钮
|
|
||||||
- 🖋️ 点击车辆 → 跳转至 `vehicle-edit.uvue`
|
|
||||||
- 🗑️ 支持删除、设为主用车等操作
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. `vehicle-add.uvue` —— **添加车辆**
|
|
||||||
- 📝 表单填写:车牌号、车型、行驶证照片、车辆类型等
|
|
||||||
- ✅ 提交后绑定到当前账户
|
|
||||||
- ⬅️ 返回车辆管理页
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. `vehicle-edit.uvue` —— **编辑车辆信息**
|
|
||||||
- 🖋️ 修改已有车辆信息(车牌、车型、照片等)
|
|
||||||
- 📸 支持重新上传行驶证照片
|
|
||||||
- ✅ 保存后更新车辆信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 页面跳转关系图(简化版)
|
|
||||||
|
|
||||||
```
|
|
||||||
index.uvue
|
|
||||||
│
|
|
||||||
├──→ profile.uvue → profile-edit.uvue
|
|
||||||
│
|
|
||||||
├──→ tasks.uvue → task-detail.uvue
|
|
||||||
│
|
|
||||||
├──→ order-history.uvue → order-detail.uvue (from=history)
|
|
||||||
│
|
|
||||||
├──→ earnings.uvue
|
|
||||||
│
|
|
||||||
├──→ ratings.uvue
|
|
||||||
│
|
|
||||||
└──→ vehicle.uvue → vehicle-add.uvue / vehicle-edit.uvue
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 设计原则
|
|
||||||
|
|
||||||
- ✅ **一致性**:所有页面使用相同导航栏样式(左上角返回按钮 + 居中标题)
|
|
||||||
- ✅ **响应式**:适配 H5、APP、小程序等平台
|
|
||||||
- ✅ **状态驱动**:根据订单状态动态显示不同操作按钮
|
|
||||||
- ✅ **用户体验优先**:历史订单跳转后只显示必要操作,避免冗余按钮
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 建议优化点
|
|
||||||
|
|
||||||
| 问题 | 建议 |
|
|
||||||
|------|------|
|
|
||||||
| `index.uvue` 重复 | 重命名为 `dashboard.uvue` 或 `home.uvue` |
|
|
||||||
| 页面间参数传递 | 使用 `uni.navigateTo({ url: '?param=value' })` 并在 `onLoad` 中接收 |
|
|
||||||
| UTS 语法兼容性 | 如遇问题,可临时改用标准 TS 语法调试 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 总结
|
|
||||||
|
|
||||||
这个 `delivery` 模块是一个完整的配送员工作系统,涵盖了:
|
|
||||||
|
|
||||||
- **个人管理**(资料、设置)
|
|
||||||
- **任务处理**(接单、配送、完成)
|
|
||||||
- **数据统计**(收入、评价、历史)
|
|
||||||
- **车辆管理**
|
|
||||||
|
|
||||||
结构清晰、功能完整,适合用于实际配送平台开发。
|
|
||||||
|
|
||||||
---
|
|
||||||
97
pages/mall/delivery/doc/old(弃用)/DELIVERY_LOGIC.md
Normal file
97
pages/mall/delivery/doc/old(弃用)/DELIVERY_LOGIC.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 配送端逻辑说明(概要与建议)(舍弃)
|
||||||
|
|
||||||
|
**概览**
|
||||||
|
- 本文档总结 `pages/mall/delivery` 目录下配送端主要页面的工作流与实现细节,定位到关键数据库表与状态机,列出已识别的并发/同步风险,并给出可执行的改进建议。
|
||||||
|
- 主要参考页面:
|
||||||
|
- [pages/mall/delivery/index.uvue](pages/mall/delivery/index.uvue)
|
||||||
|
- [pages/mall/delivery/order-history.uvue](pages/mall/delivery/order-history.uvue)
|
||||||
|
- [pages/mall/delivery/profile.uvue](pages/mall/delivery/profile.uvue)
|
||||||
|
- [pages/mall/delivery/order-detail.uvue](pages/mall/delivery/order-detail.uvue)
|
||||||
|
|
||||||
|
**主要数据表(后端)**
|
||||||
|
- `ml_delivery_drivers`:配送员信息与 `work_status`。
|
||||||
|
- `ml_delivery_tasks`:配送任务(配送端主要“真源”),包含 `driver_id, order_id, status, pickup_time, delivered_time` 等。
|
||||||
|
- `ml_orders`、`ml_order_items`:电商订单主表及明细(用于展示订单详情、订单号、商品列表等)。
|
||||||
|
- 其它辅助表:`ml_shops`, `ak_users`(用于 auth->ak id 回退查找)等。
|
||||||
|
|
||||||
|
**状态机(关键数值映射 — 汇总)
|
||||||
|
- ml_delivery_tasks.status(代码中使用):
|
||||||
|
- 1 = 待接取(pending)
|
||||||
|
- 2 = 已接取(accepted)
|
||||||
|
- 3 = 取货中(picking)
|
||||||
|
- 4 = 已取货(picked)
|
||||||
|
- 5 = 配送中(delivering / startDelivery 将 status 置 5)
|
||||||
|
- 6 = 已送达 / 完成(confirmDelivery 将 status 置 6)
|
||||||
|
- ml_orders.order_status(电商端状态,代码中做同步尝试):
|
||||||
|
- 2 = 已接取
|
||||||
|
- 4 = 已取货
|
||||||
|
- 5 = 已送达
|
||||||
|
- 7 = 已拒绝
|
||||||
|
- 注意:两张表的状态值并不一一对应,代码中多处采用“尝试同步”的方式更新 `ml_orders.order_status`,但没有统一映射抽象,可能造成短时不一致。
|
||||||
|
|
||||||
|
**页面职责与交互要点**
|
||||||
|
- `index.uvue`(配送端首页)
|
||||||
|
- 加载司机信息、今日统计、当前任务(查询 `ml_delivery_tasks`,`lt status < 5`)、可接订单(`driver_id IS NULL AND status = 1`)。
|
||||||
|
- 提供在线/离线切换(更新 `ml_delivery_drivers.work_status`),并禁止在存在当前任务时下线。
|
||||||
|
- 任务操作:接受(status->2)、开始取货(3)、确认取货(4)、开始配送(5)、确认送达(6)。每步都更新 `ml_delivery_tasks`,并尝试同步 `ml_orders`。
|
||||||
|
- 使用 `supaReady` 包装并设 1.5s 超时降级,避免阻塞 UI。
|
||||||
|
|
||||||
|
- `order-history.uvue`(历史订单)
|
||||||
|
- 以 `ml_delivery_tasks` 为源,查询 driver 相关任务(`gte('status', 2)`),并映射为历史条目供展示。
|
||||||
|
- 使用本地存储键 `completed_order_for_history`:确认送达时(index.confirmDelivery)会把完成的任务写入该键,历史页 onShow 时合并(仅当 status >= 4),之后删除该 key。
|
||||||
|
|
||||||
|
- `profile.uvue`(个人中心)
|
||||||
|
- 展示司机统计(今日、最近任务、任务计数),通过 `ml_delivery_tasks` 聚合计算 pending/ongoing/completed 数量。
|
||||||
|
|
||||||
|
- `order-detail.uvue`(订单/任务详情)
|
||||||
|
- 支持以订单 ID 或任务 ID 打开:优先查 `ml_orders`,若未找到则回退查 `ml_delivery_tasks` 并用任务数据回填页面。
|
||||||
|
- 对关键操作(接受/确认取货/确认送达/拒绝)会同时更新 `ml_delivery_tasks`(按 `order_id`)并尝试更新 `ml_orders.order_status`。
|
||||||
|
|
||||||
|
**已识别风险与可改进点**
|
||||||
|
- 状态不一致风险:
|
||||||
|
- `ml_delivery_tasks` 与 `ml_orders` 使用不同数值含义,且同步是“尝试式”的,短时内出现不一致或页面显示不同步的可能性高。
|
||||||
|
- 抢单竞态(race condition):
|
||||||
|
- 可接订单查询基于 `driver_id IS NULL AND status = 1`,多个司机并发接受可能造成重复接单。
|
||||||
|
- 目前接单操作没有使用条件更新(例如 `UPDATE ... WHERE id = ? AND driver_id IS NULL`),因此依赖后续 reload 作为补救,非原子性操作。
|
||||||
|
- 本地存储同步(`completed_order_for_history`):
|
||||||
|
- 以 localStorage 作为页面间同步的手段,适用于单设备单会话,但在多设备或短时间多页面切换(或 crash)场景下不可靠。
|
||||||
|
- UI 与状态覆盖差异:
|
||||||
|
- 某些页面/映射未覆盖 `status = 6`(例如历史页 getOrderStatusText 没有 `6` 的友好文本),会显示“未知状态”。
|
||||||
|
- supaReady 超时策略:
|
||||||
|
- 使用 1.5s 超时可以避免页面长时间等待,但在 session 刷新尚未完成的情况下可能读到空或旧数据;需要权衡 UX 与数据正确性。
|
||||||
|
|
||||||
|
**短期可执行修复(优先级排序)**
|
||||||
|
1. 接单使用条件更新(高优先)
|
||||||
|
- 在接单接口/前端更新时改为条件写入:
|
||||||
|
- SQL/SDK 示例:UPDATE ml_delivery_tasks SET driver_id = :driverId, status = 2 WHERE id = :taskId AND driver_id IS NULL
|
||||||
|
- 若返回表示 0 行更新,则提示“已被其他人接单”,并刷新列表。
|
||||||
|
- 目的:避免重复接单的竞态。
|
||||||
|
|
||||||
|
2. 统一状态映射抽象(中优先)
|
||||||
|
- 在前端添加一个状态映射工具函数(例如 mapTaskStatusToOrderStatus(status)),并在每处状态同步时使用该映射。集中维护会减少散落的 magic number。
|
||||||
|
|
||||||
|
3. 增强表间同步可靠性(中优先)
|
||||||
|
- 将关键同步(例如确认送达)封装为后端事务或 RPC:后端在事务中同时更新 `ml_delivery_tasks` 与 `ml_orders`,并返回原子成功/失败。前端只负责触发该 API。
|
||||||
|
|
||||||
|
4. 替换 localStorage 同步(低优先)
|
||||||
|
- 使用队列/事件或服务端记录(例如在确认送达时写入 `ml_delivery_tasks`,历史页直接查询,不依赖 localStorage 写入),或者使用 WebSocket / 推送通知触发历史刷新。
|
||||||
|
|
||||||
|
5. 补全 UI 映射(低优先)
|
||||||
|
- 为 `status = 6` 等补全友好文本和样式,避免“未知状态”展示。
|
||||||
|
|
||||||
|
**建议的具体代码调整点(快速指引)**
|
||||||
|
- 在 `index.uvue` 与 `order-detail.uvue` 的接单逻辑中替换无条件 update 为条件更新。例如:
|
||||||
|
- 当前(伪代码):
|
||||||
|
- supa.from('ml_delivery_tasks').update({ driver_id: driverId, status: 2 }).eq('id', orderId)
|
||||||
|
- 建议(伪代码):
|
||||||
|
- supa.rpc 或 SQL: UPDATE ml_delivery_tasks SET driver_id = $1, status = 2 WHERE id = $2 AND driver_id IS NULL RETURNING *
|
||||||
|
- 如果返回行数 === 0 则表示接单失败(已被抢)
|
||||||
|
- 在多个页面里抽象出 `statusMap` 函数并复用:放在 `utils/deliveryStatus.uts` 或相应工具文件。
|
||||||
|
|
||||||
|
**操作建议(下一步)**
|
||||||
|
- 我可以为你:
|
||||||
|
1. 在代码中实现“条件接单”补丁(修改 `index.uvue` 与 `order-detail.uvue` 的 `acceptOrder/acceptTask` 函数),并提交一个 patch;或者
|
||||||
|
2. 生成一个状态映射工具文件并替换当前页面中的硬编码映射;或者
|
||||||
|
3. 把本文档整理为项目 wiki 条目并打开 PR。
|
||||||
|
|
||||||
|
请选择一个你希望我继续执行的项(例如“帮我做 1:实现条件接单补丁”),我会把对应步骤加入 TODO 并实现。
|
||||||
37
pages/mall/delivery/doc/old(弃用)/FIX_REPORT_20260203.md
Normal file
37
pages/mall/delivery/doc/old(弃用)/FIX_REPORT_20260203.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 问题修复汇总报告 (2026-02-03)
|
||||||
|
|
||||||
|
## 1. 登录后无法进入首页的问题
|
||||||
|
**现象**:用户登录成功后页面无跳转,或者直接跳到了管理/分析后台。
|
||||||
|
**原因**:
|
||||||
|
- `pages.json` 中 `pages/mall/admin/homePage/index` 排在首位,导致默认入口偏向管理端。
|
||||||
|
- `ak/config.uts` 中的 `IS_TEST_MODE` 开启时,登录页和启动页禁用了自动重定向。
|
||||||
|
- 登录逻辑中缺少对 `setIsLoggedIn` 的调用,导致全局状态未激活。
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
- 修改 `pages/user/login.uvue`,无论是否在测试模式,登录成功后显式执行 `uni.switchTab` 跳转至 `/pages/mall/consumer/index`。
|
||||||
|
- 补全了 `utils/store.uts` 中相关状态管理函数的导入。
|
||||||
|
|
||||||
|
## 2. 分析页面 (Profile) 报错
|
||||||
|
**现象**:编译时提示 `Unexpected token`,页面无法打开。
|
||||||
|
**原因**:
|
||||||
|
- `pages/mall/analytics/profile.uvue` 中的 `loadReportCounts` 函数缺少一个闭合花括号 `}`,导致语法解析异常。
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
- 在 `pages/mall/analytics/profile.uvue` 中补全了完整的代码结构。
|
||||||
|
|
||||||
|
## 3. H5 页面刷新后数据消失 (0数据)
|
||||||
|
**现象**:在配送端或用户中心,刷新页面后“今日统计”或“用户信息”变为 0 或空。
|
||||||
|
**原因**:
|
||||||
|
- 在 H5 环境下,刷新会导致内存中的变量(Vue Reactive State)重置。
|
||||||
|
- 如果 Supabase Session 恢复较慢,或者使用了模拟账号(如 `admin`),会导致系统判定用户未登录,从而查询不到 ID。
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
- **增强持久化**:在 `utils/store.uts` 的 `getCurrentUserId` 函数中加入了本地存储(LocalStorage)兜底。
|
||||||
|
- **登录保存**:在登录成功瞬间,将 `user_id` 写入 `uni.setStorageSync('user_id', ...)`。
|
||||||
|
- **模拟账号优化**:针对 `admin` 账号在刷新后不会因为没有远程 Session 而被踢出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待核对事项
|
||||||
|
- **首页顺序**:如果需要默认进入移动全商城而非管理后台,请调整 `pages.json` 的数组顺序。
|
||||||
|
- **订单数据**:目前“附近订单”在日志中显示有数据,但若在界面显示不全,请检查 `ml_delivery_tasks` 表中的 `status` 是否符合筛选条件。
|
||||||
81
pages/mall/delivery/doc/old(弃用)/STATE_1-5_LOGIC.md
Normal file
81
pages/mall/delivery/doc/old(弃用)/STATE_1-5_LOGIC.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 状态 1–5 行为与代码位置说明
|
||||||
|
|
||||||
|
本文档针对 `ml_delivery_tasks.status` 中 1–5 的状态,逐项列出在 delivery 页面中涉及的展示、查询、更新位置、按钮/动作与已发现的不一致点与建议。
|
||||||
|
|
||||||
|
## 状态 1 — 待接取
|
||||||
|
- 映射文本/样式
|
||||||
|
- `index.uvue`: `getTaskStatusClass` -> `task-pending`(见 index.uvue getTaskStatusClass)
|
||||||
|
- `index.uvue`: `getTaskStatusText` case 1 -> '待接取'(index.uvue#L571)
|
||||||
|
- `order-history.uvue`: 同样映射(order-history.uvue#L230, #L242)
|
||||||
|
- 查询/展示
|
||||||
|
- `loadAvailableOrders` 查询:`supa.from('ml_delivery_tasks').is('driver_id','null').eq('status', 1)`(index.uvue loadAvailableOrders)
|
||||||
|
- 在可接订单列表与当前任务区显示“接受任务/接受订单”按钮(index.uvue#L81、order card accept button)
|
||||||
|
- 更新/操作
|
||||||
|
- 接单时更新为 `status = 2`:`supa.from('ml_delivery_tasks').update({ driver_id: driverId, status: 2 }).eq('id', ...)`(index.uvue acceptTask / acceptOrder,index.uvue#L602,#L756)
|
||||||
|
- 建议/注意
|
||||||
|
- 接单使用条件更新或后端 RPC 避免抢单竞态(目前无 WHERE driver_id IS NULL 条件)。
|
||||||
|
|
||||||
|
## 状态 2 — 已接取
|
||||||
|
- 映射文本/样式
|
||||||
|
- `index.uvue` 文本 '已接取'(index.uvue#L572),样式 `task-accepted`(index.uvue#L560)
|
||||||
|
- `order-history.uvue` 相应映射(order-history.uvue#L243)
|
||||||
|
- 查询/展示
|
||||||
|
- 当前任务区显示“开始取货”按钮(index.uvue#L82)
|
||||||
|
- `order-detail.uvue` 若 `order.status === 2` 显示“前往取货/正在取货”(order-detail.uvue#L169-L170)
|
||||||
|
- 更新/操作
|
||||||
|
- `startPickup()` 会将 task 状态更新为 3(index.uvue startPickup)
|
||||||
|
- 接单后代码尝试同步 `ml_orders.order_status = 2`(index.uvue#L766)
|
||||||
|
- 建议/注意
|
||||||
|
- 同步 ml_orders 的操作应做失败回退处理或在服务端做原子事务。
|
||||||
|
|
||||||
|
## 状态 3 — 取货中
|
||||||
|
- 映射文本/样式
|
||||||
|
- `index.uvue` 文本 '取货中'(index.uvue#L573),样式 `task-picking`(index.uvue#L561)
|
||||||
|
- `order-history.uvue` 同步映射(order-history.uvue#L244)
|
||||||
|
- 查询/展示
|
||||||
|
- 当前任务区显示“确认取货”按钮(index.uvue#L83)
|
||||||
|
- `order-detail.uvue` 在 route 区显示“确认取货”按钮(order-detail.uvue#L55,#L173)
|
||||||
|
- 更新/操作
|
||||||
|
- `confirmPickup()` 将 status -> 4 并写入 `pickup_time`,同时尝试把 `ml_orders.order_status = 4`(index.uvue & order-detail)
|
||||||
|
- 建议/注意
|
||||||
|
- 确认取货应当原子更新任务与订单状态,或由后端封装接口完成。
|
||||||
|
|
||||||
|
## 状态 4 — 已取货
|
||||||
|
- 映射文本/样式
|
||||||
|
- `index.uvue` 文本 '已取货'(index.uvue#L574),样式 `task-picked`(index.uvue#L562)
|
||||||
|
- `order-history.uvue` 文本 '已取货'(order-history.uvue#L245)
|
||||||
|
- 查询/展示
|
||||||
|
- 当前任务区显示“开始配送”按钮(index.uvue#L84)
|
||||||
|
- `order-detail.uvue` 显示“确认送达”按钮(order-detail.uvue#L68,#L176)
|
||||||
|
- 更新/操作
|
||||||
|
- `startDelivery()` 将 status -> 5(index.uvue startDelivery)
|
||||||
|
- 建议/注意
|
||||||
|
- 明确 4 与 5 的边界并在统计/历史中统一口径(是否将 5 视作已完成)。
|
||||||
|
|
||||||
|
## 状态 5 — 配送中
|
||||||
|
- 映射文本/样式
|
||||||
|
- `index.uvue` 文本 '配送中'(index.uvue#L575),样式 `task-delivering`(index.uvue#L563)
|
||||||
|
- `order-history.uvue` 在样式处标为 `status-delivered`(order-history.uvue#L234)且文本处 `case 5: return '已完成'`(order-history.uvue#L246)——存在不一致。
|
||||||
|
- 查询/展示
|
||||||
|
- 当前任务区显示“确认送达”入口(index.uvue#L85 -> showConfirmDeliveryDialog)
|
||||||
|
- `order-detail.uvue` 在状态 >=5 时进度条显示为已送达(getStatusDesc)
|
||||||
|
- 更新/操作
|
||||||
|
- `confirmDelivery()` 将 task.status -> 6 并写入 `delivered_time`,再尝试同步 `ml_orders.order_status = 5`(index.uvue#L679-691)
|
||||||
|
- 建议/注意
|
||||||
|
- 目前统计(如 todayStats)将 `status >= 5` 视作“已完成”计数,而 UI 有时把 5 显示为“配送中”,建议统一规则(推荐:把 6 视作真正完成,5 为配送中)。
|
||||||
|
|
||||||
|
## 跨页不一致与改进建议(摘要)
|
||||||
|
- 不一致点:`status = 5` 在不同页面既被视作“配送中”,又被视作“已完成”;`confirmDelivery` 设置 task=6,但统计使用 `>=5` 判断完成,导致计数或展示差异。
|
||||||
|
- 建议:
|
||||||
|
1. 明确“完成”阈值(推荐:status === 6 为已完成,status === 5 为配送中),并在所有统计、过滤、UI 映射处统一实现。
|
||||||
|
2. 将状态映射抽到共享工具文件 `utils/deliveryStatus.uts` 并替换硬编码。
|
||||||
|
3. 接单、确认取货、确认送达等关键操作应使用后端事务或条件更新避免竞态与不一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
文档已生成于 `pages/mall/delivery/STATE_1-5_LOGIC.md`。要我现在:
|
||||||
|
- A) 将 `getTaskStatusText` / `getOrderStatusText` 抽到 `utils/deliveryStatus.uts` 并替换页面中的映射,或
|
||||||
|
- B) 实现接单的条件更新补丁(修改 `acceptTask` / `acceptOrder`),或
|
||||||
|
- C) 仅把“5 是否计作完成”的规范改写回 `DELIVERY_LOGIC.md` 并提交。
|
||||||
|
|
||||||
|
请选择一项继续。
|
||||||
51
pages/mall/delivery/doc/old(弃用)/all.md
Normal file
51
pages/mall/delivery/doc/old(弃用)/all.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
## 1. 功能概述
|
||||||
|
`all.uvue` 是为配送端设计的全量订单抓取页面。当首页待接订单超过 5 个时,用户可通过此页面查看并抢单。
|
||||||
|
|
||||||
|
## 2. 核心设计说明
|
||||||
|
根据最新 UI 指标,该页面采用了**现代垂直流布局**:
|
||||||
|
- **中心费用展示**:订单金额在卡片顶部居中加粗显示,强化利益点。
|
||||||
|
- **垂直路径流**:采用垂直排布的取货(📍)与送达(🏠)地址,中间以箭头连接,更符合手机屏阅读习惯。
|
||||||
|
- **全宽操作按钮**:底部采用 100% 宽度的按钮,提高抢单操作的触达率。
|
||||||
|
- **实时空态处理**:当订单被他人抢先接走时,列表会自动更新。
|
||||||
|
|
||||||
|
## 3. 技术实现要点
|
||||||
|
|
||||||
|
### 3.1 数据安全与并发控制
|
||||||
|
在执行 `acceptOrder`(抢单)时,通过数据库约束确保操作的原子性:
|
||||||
|
```uts
|
||||||
|
// 增加 driver_id 为空的前提条件,防止已被他人接单
|
||||||
|
const res = await supa.from('ml_delivery_tasks')
|
||||||
|
.update({
|
||||||
|
driver_id: driverId,
|
||||||
|
status: 2,
|
||||||
|
accepted_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', taskId)
|
||||||
|
.is('driver_id', 'null') // 关键:确保单子还没被接
|
||||||
|
.execute();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 数据结构转换 (`_transformTask`)
|
||||||
|
为了兼容数据库存储的 JSON 字符串格式与 UI 组件所需的 Object 格式,页面内置了转换逻辑:
|
||||||
|
- 自动解析 `pickup_address` 和 `delivery_address` JSON 字符串。
|
||||||
|
- 格式化 `delivery_fee` 为保留两位小数。
|
||||||
|
- 映射状态码到对应的 UI 标签。
|
||||||
|
|
||||||
|
### 3.3 路由与交互
|
||||||
|
- **路由路径**:`pages/mall/delivery/all`
|
||||||
|
- **导航栏**:配置了 `navigationBarTitleText: "待接订单"`,并开启了原生的回退功能。
|
||||||
|
- **动态跳转**:支持从详情页返回后自动刷新列表(通过 `onShow` 触发)。
|
||||||
|
|
||||||
|
## 4. 样式规范
|
||||||
|
- 布局:Flexbox (Column)
|
||||||
|
- 配色:
|
||||||
|
- 取货点:Pink (`#ff4d94`)
|
||||||
|
- 送达点:Blue (`#2196F3`)
|
||||||
|
- 主按钮:Green (`#4CAF50`)
|
||||||
|
- 间距:标准 30rpx 外边距,卡片内 40rpx 内边距。
|
||||||
|
|
||||||
|
## 5. 开发历史
|
||||||
|
- **2026-02-03**: 页面初版创建。
|
||||||
|
- **2026-02-03**: 完成从水平卡片到垂直流卡片的 UI 重构。
|
||||||
|
- **2026-02-03**: 接入 Supabase 实现秒级抢单逻辑与状态同步。
|
||||||
@@ -58,6 +58,8 @@ const result = Array.from(orderMap.values())
|
|||||||
## 数据源更新:包含已接订单(Accepted)
|
## 数据源更新:包含已接订单(Accepted)
|
||||||
- 需求说明:为了与“历史订单”页面保持一致,收入统计应包含配送员已接取(accepted/assigned)的订单,不仅限于已完成订单。也就是说,只要 `ml_delivery_tasks` 中 `driver_id` = 当前司机且 `status >= 2`(已接取或进行中),其对应的订单都应计入收入统计范围。
|
- 需求说明:为了与“历史订单”页面保持一致,收入统计应包含配送员已接取(accepted/assigned)的订单,不仅限于已完成订单。也就是说,只要 `ml_delivery_tasks` 中 `driver_id` = 当前司机且 `status >= 2`(已接取或进行中),其对应的订单都应计入收入统计范围。
|
||||||
|
|
||||||
|
- 需求说明(已采纳):收入统计以 `ml_delivery_tasks` 为数据源之一:只要任务记录显示 `driver_id` = 当前司机且 `status >= 2`(已接取或进行中),其对应订单即应计入统计范围。注意:部分 `ml_delivery_tasks` 可能没有可匹配的 `ml_orders`(`order_id` 为空或在 `ml_orders` 中找不到),页面/后端应对缺失 `order_no` 做回退显示并记录供核查。
|
||||||
|
|
||||||
- 推荐 Supabase 查询示例(后端或前端按需实现):
|
- 推荐 Supabase 查询示例(后端或前端按需实现):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -78,6 +80,7 @@ LIMIT :size OFFSET :offset;
|
|||||||
```js
|
```js
|
||||||
const taskRes = await supa.from('ml_delivery_tasks').select('order_id').eq('driver_id', uid).gte('status', 2).execute()
|
const taskRes = await supa.from('ml_delivery_tasks').select('order_id').eq('driver_id', uid).gte('status', 2).execute()
|
||||||
const orderIds = taskRes.data.map(r => r.order_id)
|
const orderIds = taskRes.data.map(r => r.order_id)
|
||||||
|
// 对于 order_id 为空或找不到的情况,server 端应记录这些 id 以便排查
|
||||||
const ordersRes = await supa.from('ml_orders').select('*,ml_delivery_tips(*)').in('id', orderIds).order('created_at',{ascending:false}).limit(size).execute()
|
const ordersRes = await supa.from('ml_orders').select('*,ml_delivery_tips(*)').in('id', orderIds).order('created_at',{ascending:false}).limit(size).execute()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,6 +46,16 @@ await supa.from('ml_delivery_tasks')
|
|||||||
- `toggleWorkStatus()`:切换 `isOnline` 并调用 `startWork()` / `stopWork()`。上线时会刷新可接订单列表。
|
- `toggleWorkStatus()`:切换 `isOnline` 并调用 `startWork()` / `stopWork()`。上线时会刷新可接订单列表。
|
||||||
- 接单/开始取货/确认取货/开始配送/确认送达等均通过对 `ml_delivery_tasks` 的 `update` 操作变更 `status`,并在成功后更新本地 `currentTask`。
|
- 接单/开始取货/确认取货/开始配送/确认送达等均通过对 `ml_delivery_tasks` 的 `update` 操作变更 `status`,并在成功后更新本地 `currentTask`。
|
||||||
|
|
||||||
|
## UI 行为变动(已生效)
|
||||||
|
- 当页面检测到存在 `currentTask`(来自 `ml_delivery_tasks`)时,页面不会再弹出“附近可接订单”列表。即:配送端以 `ml_delivery_tasks` 为状态真源,主页面的订单展示不再依赖或回退展示 `ml_orders.order_status`,以避免两表不同步导致的显示冲突。
|
||||||
|
- 为了减少页面闪烁与重复刷新,`enableAutoRefresh` 在默认实现中已可被关闭(`false`),且 `loadAvailableOrders()` 在检测到 `currentTask` 时会跳过可接订单的渲染。
|
||||||
|
|
||||||
|
## 会话与加载保护
|
||||||
|
- `supaReady` 的会话恢复在某些环境中会较慢,页面中已对其使用 `Promise.race(..., 1500)` 超时包装:超时后页面会打警告并继续渲染以避免长时间阻塞用户界面。依赖用户 id 的查询在超时情况下可能为空,请参照 `order-history.md` 的“已实现的防护”部分进行排查。
|
||||||
|
|
||||||
|
## 前端同步尝试(临时)
|
||||||
|
- 在接单/确认送达流程中前端会尝试向 `ml_orders` 发送更新(将 `order_status` 同步到任务的状态)并记录返回结果用于诊断。但该同步并不保证在所有权限或网络错误下成功,因此更稳妥的方案仍是后端触发器同步或可信服务端接口。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
- 高并发接单场景需后端保证原子性(乐观锁或 DB 事务)以防止竞单冲突。
|
- 高并发接单场景需后端保证原子性(乐观锁或 DB 事务)以防止竞单冲突。
|
||||||
- `loadAvailableOrders()` 最好按司机服务区域与距离筛选,并使用分页/实时推送代替频繁轮询。
|
- `loadAvailableOrders()` 最好按司机服务区域与距离筛选,并使用分页/实时推送代替频繁轮询。
|
||||||
47
pages/mall/delivery/doc/old(弃用)/order-detail.md
Normal file
47
pages/mall/delivery/doc/old(弃用)/order-detail.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# order-detail.uvue — 订单详情
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
展示单笔订单全部信息并提供状态流转(接单、确认取货、确认送达、拒单等)。页面会联合查询 `ml_orders`、`ml_order_items`、`ml_shops` 与 `ml_delivery_tasks`。
|
||||||
|
|
||||||
|
## 数据结构(常用字段)
|
||||||
|
- `Order`
|
||||||
|
- `id: string | number`
|
||||||
|
- `order_no: string`
|
||||||
|
- `cid?: number` (兼容旧字段)
|
||||||
|
- `shipping_address | pickup_address`: object/string
|
||||||
|
- `shipping_fee`, `total_amount`, `status`
|
||||||
|
|
||||||
|
- `OrderItem`
|
||||||
|
- `id`, `order_id`, `product_name`, `qty`, `price`
|
||||||
|
|
||||||
|
- `DeliveryTask`
|
||||||
|
- `id`, `order_id`, `driver_id`, `status`, `accepted_at`, `picked_at`, `delivered_at`
|
||||||
|
|
||||||
|
## 关键方法
|
||||||
|
- `onLoad(options)`
|
||||||
|
- 解析 `options.id` 与 `options.status`,调用 `loadOrderDetail(id)`。
|
||||||
|
|
||||||
|
- `loadOrderDetail(id)`
|
||||||
|
- **加载保护**:对 `supaReady` 采用 1.5s 超时策略包装,防止会话刷新阻塞页面加载。
|
||||||
|
- **智能 ID 回查**:优先从 `ml_orders` (UUID/cid/order_no) 查找。若未找到,则尝试从未分配任务表 `ml_delivery_tasks` 中根据 ID 查找,再反查关联订单。
|
||||||
|
- **降级机制(Fallback)**:当 `ml_orders` 行缺失时,自动回退到从 `ml_delivery_tasks` 提取地址、手机号、配送费及距离等基础信息进行展示,并在 UI 上显示回退加载提示。
|
||||||
|
- **清理加载状态**:在 `finally` 块中统一切除加载动画 (`uni.hideLoading`),防止界面挂起。
|
||||||
|
|
||||||
|
## 交互与样式优化(2026-02-03 更新)
|
||||||
|
- **联系人信息解析**:地址栏(取货/送货)现在仅在“联系人姓名”存在时显示分隔点 `·`。若无姓名,则仅显示手机号,避免显示为 `. 手机号`。
|
||||||
|
- **联系方式布局优化**:为了防止手机号在不同屏幕宽度下被图标遮挡,联系人区域采用**垂直居中布局**(图标在上,姓名电话在下),显著拉高了边框高度 (`min-height: 180rpx`) 并增加了垂直间距。
|
||||||
|
- **文本显示优化**:
|
||||||
|
- 商品区域的“**订单号**”增加了加粗显示 (`bold`),提升核对便利性。
|
||||||
|
- 展示给配送员的联系手机号调大了字号并加粗,确保清晰可见。
|
||||||
|
- 修复了在 Uni-app x 下由于类型推断导致的手机号无法通过点语法访问的问题(改用索引访问)。
|
||||||
|
|
||||||
|
## 示例:地址兼容解析逻辑
|
||||||
|
```typescript
|
||||||
|
// 兼容 JSON 字符串及对象格式的地址字段
|
||||||
|
let shipping = {}
|
||||||
|
if (typeof raw == 'string') {
|
||||||
|
try { shipping = JSON.parse(raw) } catch (e) { shipping = { detail: raw } }
|
||||||
|
} else { shipping = raw || {} }
|
||||||
|
// 访问方式:(address as UTSJSONObject)['phone']
|
||||||
|
```
|
||||||
|
|
||||||
106
pages/mall/delivery/doc/old(弃用)/order-history.md
Normal file
106
pages/mall/delivery/doc/old(弃用)/order-history.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 历史订单 页面说明(order-history.uvue)
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
`order-history.uvue` 用于配送员查看历史订单与近期任务。页面会展示:
|
||||||
|
- 以 `ml_delivery_tasks` 为配送端“状态真源”的任务记录(只要 `driver_id = 当前司机` 且 `status >= 2`,均会包含在统计/列表中);
|
||||||
|
- 页面会批量回填对应 `ml_orders.order_no` 以补全显示(若 `order_no` 缺失会显示回退文本),避免直接以 `ml_orders.order_status` 作为展示依据而导致与配送端不一致。
|
||||||
|
|
||||||
|
页面关键点:
|
||||||
|
- 首次加载时通过 `loadOrderHistory()` 拉取数据;页面每次显示时会检查本地存储 `completed_order_for_history`,并把刚完成订单插入列表表头。
|
||||||
|
- 使用 Supabase 客户端 `supa` 读取 `ml_delivery_tasks` 与 `ml_orders` 表,并通过 `getCurrentUser()` / `getCurrentUserId()` 获取当前用户/司机 id。
|
||||||
|
|
||||||
|
## 行为细节
|
||||||
|
- 当前实现优先以 `ml_delivery_tasks`(status >= 2)作为数据源,页面会:
|
||||||
|
- 查询 `ml_delivery_tasks` 中与 `driver_id` 相关的任务,按时间排序并映射为页面项;
|
||||||
|
- 对获取到的 `order_id` 列表做一次批量查询 `ml_orders` 以回填 `order_no` 和订单详情;
|
||||||
|
- 对没有匹配到 `ml_orders` 的 `order_id`,页面会用短 id 回退显示并在控制台打印缺失 id 列表,便于后台核查数据不一致的原因。
|
||||||
|
- 为避免重复展示,页面在将“当前任务对应订单”插入顶部时,会先检查 `orderList` 是否已有相同 `id`。
|
||||||
|
|
||||||
|
## 依赖 & 相关文件
|
||||||
|
- 页面文件:`pages/mall/delivery/order-history.uvue`(当前)
|
||||||
|
- Supabase 实例:`components/supadb/aksupainstance.uts`(导出 `supa` 与 `supaReady`)
|
||||||
|
- 用户/会话工具:`utils/store.uts`(`getCurrentUser()`、`getCurrentUserId()`)
|
||||||
|
- 相关文档:
|
||||||
|
- `pages/mall/delivery/doc/earnings.md`(收入聚合与 DB 建议)
|
||||||
|
- `pages/mall/delivery/doc/test-user-1_at_123.com.md`(测试用户与 SQL 示例)
|
||||||
|
|
||||||
|
## 已实现的防护与诊断信息
|
||||||
|
- `supaReady` 在会话恢复时可能会进行网络刷新(refreshSession),该步骤可能较慢。为避免页面长时间阻塞,页面中对 `supaReady` 使用了 `Promise.race` 的 1.5s 超时包装:如果超时会打印警告并继续执行(某些依赖用户 id 的查询可能因此为空)。
|
||||||
|
- 如果 `getCurrentUserId()` 返回空,页面会尝试从 `supa.getSession()` 获取 auth id 并在 `ak_users` 表中查找对应的 `ak_users.id` 作为回退,这能修复 `driver_id` 在数据库中为 `ak_users.id` 的常见映射问题。
|
||||||
|
|
||||||
|
## 常见不一致现象说明
|
||||||
|
- 我们观察到的常见情况:`ml_delivery_tasks` 中的任务显示为“已完成”(配送端),但对应 `ml_orders.order_status` 仍为“已取消”或其它状态,导致不同页面显示冲突。原因通常为:
|
||||||
|
- `ml_delivery_tasks.order_id` 为空或格式不一致(UUID vs string);
|
||||||
|
- `ml_orders` 没有相应行(数据尚未同步或被删除);
|
||||||
|
- RLS/权限导致前端不能读取或更新 `ml_orders`。
|
||||||
|
|
||||||
|
建议排查 SQL(例):
|
||||||
|
```
|
||||||
|
SELECT t.id AS task_id, t.order_id, t.status AS task_status, o.order_status
|
||||||
|
FROM public.ml_delivery_tasks t
|
||||||
|
LEFT JOIN public.ml_orders o ON o.id = t.order_id
|
||||||
|
WHERE t.status >= 2
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
LIMIT 200;
|
||||||
|
```
|
||||||
|
|
||||||
|
如需我为你生成触发器或前端重试队列示例,我可以继续实现。
|
||||||
|
|
||||||
|
## 常见问题与排查步骤
|
||||||
|
1. 问题:页面没有显示当前已接订单(即使首页显示有当前任务)。
|
||||||
|
- 检查控制台日志:页面会打印 `loadOrderHistory: currentUserId=`、`loadOrderHistory: session id fallback=`、`loadOrderHistory: delivery_tasks dtRes=`、`loadOrderHistory: ordersRes=`。把这些日志逐项核对:
|
||||||
|
- `currentUserId` 应为 `ak_users.id`(或系统实际使用的 driver id)。
|
||||||
|
- `dtRes`(delivery_tasks 查询)应包含对象数组,且数组项含 `order_id`。
|
||||||
|
- `ordersRes` 应包含对应的 `ml_orders` 行。
|
||||||
|
- 若 `dtRes` 为空且 `session id fallback` 有值,说明 `ak_users` 表中可能没有把 auth id 映射到 `ak_users.id`,需要把 `ak_users.auth_id` 填入或同步。
|
||||||
|
- 若 `ordersRes` 为空,但 `dtRes` 非空,请检查 `ml_orders.id` 与 `ml_delivery_tasks.order_id` 的数据类型(例如 UUID vs string)以及 RLS 策略。
|
||||||
|
|
||||||
|
2. 问题:页面加载慢或时不时刷新。
|
||||||
|
- 原因:多个页面在 `onShow`/`onLoad` 时发起多次 supa 查询,且 `supaReady` 恢复会话有时较慢,导致累积延迟。已在 `index.uvue` 增加了防抖与 `enableAutoRefresh` 开关来禁止自动刷新。
|
||||||
|
- 排查:查看控制台是否有 `supaReady timeout/failed` 警告(若有,则说明会话恢复慢或失败)。
|
||||||
|
|
||||||
|
## 性能与安全建议
|
||||||
|
- 若数据量大,请在后端做分页与聚合(只返回必要字段);避免一次性查询大量 `ml_orders` 字段。参见 `earnings.md` 中的后端接口建议。
|
||||||
|
- 长期建议:修改 `components/supadb/aksupainstance.uts` 中会话恢复逻辑,让刷新在后台异步进行或提供可配置的超时策略,避免阻塞页面加载。
|
||||||
|
|
||||||
|
## 测试步骤(快速)
|
||||||
|
1. 使用测试用户(参见 `test-user-1_at_123.com.md`)创建一个 `ml_delivery_tasks` 记录,`driver_id` 对应当前司机,且 `status >= 2`。
|
||||||
|
2. 在首页确认当前任务显示;然后打开“历史订单”页面,观察顶部是否显示该订单。若未显示,贴上控制台中 `loadOrderHistory:` 的相关日志给开发者排查。
|
||||||
|
|
||||||
|
## 变更历史
|
||||||
|
- 2026-02-02:添加回退 mapping(session -> ak_users.id)、supaReady 超时保护的说明、调试日志建议及性能建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如需我把文档翻译为英文或生成 README 风格的一页说明,我可以继续补充。
|
||||||
|
# order-history.uvue — 历史订单
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
显示配送员历史订单(已完成/已取消等),并支持按时间范围过滤、分页和插入刚完成订单以做到“即时显示”。
|
||||||
|
|
||||||
|
## 数据结构
|
||||||
|
- `HistoryOrder`
|
||||||
|
- `order_no`, `id`, `status`, `delivered_at`, `total_amount`, `shop_name`
|
||||||
|
|
||||||
|
## 关键方法
|
||||||
|
- `loadOrderHistory({start,end,page,size})`
|
||||||
|
- 优先按 `ml_delivery_tasks` 中与 `driver_id` 相关的 `order_id` 拉取任务记录,再批量查询 `ml_orders` 获取详情。
|
||||||
|
- 若后端支持直接按 `driver_id` 返回已完成订单则调用后端聚合接口更高效。
|
||||||
|
|
||||||
|
- `checkForNewCompletedOrder()`
|
||||||
|
- 从 `uni.getStorageSync('completed_order_for_history')` 读取并合并到 `orderHistory` 顶部,随后清除本地缓存键。
|
||||||
|
|
||||||
|
## DB 查询示例(伪 SQL / supa)
|
||||||
|
```
|
||||||
|
const tasks = await supa.from('ml_delivery_tasks').select('order_id,delivered_at').eq('driver_id', driverId).eq('status', 4).order('delivered_at', { ascending: false }).limit(100).execute()
|
||||||
|
const orders = await supa.from('ml_orders').select('*').in('id', tasks.map(t=>t.order_id)).execute()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分页与筛选
|
||||||
|
- 使用后端分页(`page/size`),前端仅负责渲染和“加载更多”。
|
||||||
|
- 支持按日期区间和商家名模糊搜索。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
- 当从本地合并刚完成订单时,去重逻辑必不可少(按 `order_no` 或 `id`)。
|
||||||
|
- 对于大量历史数据,应依赖后端支持归档与按需加载。错误/异常应有兜底 UI(空状态/重试按钮)。
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# order-detail.uvue — 订单详情
|
|
||||||
|
|
||||||
## 概要
|
|
||||||
展示单笔订单全部信息并提供状态流转(接单、确认取货、确认送达、拒单等)。页面会联合查询 `ml_orders`、`ml_order_items`、`ml_shops` 与 `ml_delivery_tasks`。
|
|
||||||
|
|
||||||
## 数据结构(常用字段)
|
|
||||||
- `Order`
|
|
||||||
- `id: string | number`
|
|
||||||
- `order_no: string`
|
|
||||||
- `cid?: number` (兼容旧字段)
|
|
||||||
- `shipping_address | pickup_address`: object/string
|
|
||||||
- `shipping_fee`, `total_amount`, `status`
|
|
||||||
|
|
||||||
- `OrderItem`
|
|
||||||
- `id`, `order_id`, `product_name`, `qty`, `price`
|
|
||||||
|
|
||||||
- `DeliveryTask`
|
|
||||||
- `id`, `order_id`, `driver_id`, `status`, `accepted_at`, `picked_at`, `delivered_at`
|
|
||||||
|
|
||||||
## 关键方法
|
|
||||||
- `onLoad(options)`
|
|
||||||
- 解析 `options.id` 与 `options.status`,调用 `loadOrderDetail(id)`。
|
|
||||||
|
|
||||||
- `loadOrderDetail(id)`
|
|
||||||
- 判断 ID 类型(UUID / 数字 / 非数字)以决定查询字段(`id`、`cid`、`order_no`)。
|
|
||||||
- 并行查询 `ml_orders`, `ml_order_items`, `ml_shops`, `ml_delivery_tasks` 并合并到页面状态。
|
|
||||||
|
|
||||||
- `acceptOrder()` / `rejectOrder(reason)`
|
|
||||||
- accept: 尝试对 `ml_delivery_tasks` 执行 `update driver_id` 操作并设置 `status=2`(处理中),需要后端并发保护。
|
|
||||||
- reject: 增加拒单原因到 `ml_delivery_tasks` 或 `order_notes` 并回滚本地 UI 状态。
|
|
||||||
|
|
||||||
- `confirmPickup()` / `confirmDelivery()`
|
|
||||||
- 根据 `task.id` 更新相应时间戳字段(`picked_at`/`delivered_at`)并设置状态(例如 `status=3/4`)。
|
|
||||||
|
|
||||||
## 示例:按 id 类型查询(伪代码)
|
|
||||||
```
|
|
||||||
let q = supa.from('ml_orders').select('*')
|
|
||||||
if (isUUID(id)) q = q.eq('id', id)
|
|
||||||
else if (isNumeric(id)) q = q.eq('cid', id)
|
|
||||||
else q = q.eq('order_no', id)
|
|
||||||
const { data: order } = await q.limit(1).execute()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 事务与并发注意
|
|
||||||
- 接单场景应使用后端原子性检查(数据库事务或行级乐观锁)以避免多司机同时接单。
|
|
||||||
- 前端接单流程:先尝试 update(带 where driver_id IS NULL),若返回 0 row affected 则提示已被接单。
|
|
||||||
|
|
||||||
## 错误处理与回退
|
|
||||||
- 捕获所有 supa 调用错误并将友好错误展示给用户(例如:'网络错误,请稍后重试')。
|
|
||||||
- 对可能缺失的字段(地址为字符串或对象)使用 `_transformAddress()` 做兼容处理。
|
|
||||||
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# order-history.uvue — 历史订单
|
|
||||||
|
|
||||||
## 概要
|
|
||||||
显示配送员历史订单(已完成/已取消等),并支持按时间范围过滤、分页和插入刚完成订单以做到“即时显示”。
|
|
||||||
|
|
||||||
## 数据结构
|
|
||||||
- `HistoryOrder`
|
|
||||||
- `order_no`, `id`, `status`, `delivered_at`, `total_amount`, `shop_name`
|
|
||||||
|
|
||||||
## 关键方法
|
|
||||||
- `loadOrderHistory({start,end,page,size})`
|
|
||||||
- 优先按 `ml_delivery_tasks` 中与 `driver_id` 相关的 `order_id` 拉取任务记录,再批量查询 `ml_orders` 获取详情。
|
|
||||||
- 若后端支持直接按 `driver_id` 返回已完成订单则调用后端聚合接口更高效。
|
|
||||||
|
|
||||||
- `checkForNewCompletedOrder()`
|
|
||||||
- 从 `uni.getStorageSync('completed_order_for_history')` 读取并合并到 `orderHistory` 顶部,随后清除本地缓存键。
|
|
||||||
|
|
||||||
## DB 查询示例(伪 SQL / supa)
|
|
||||||
```
|
|
||||||
const tasks = await supa.from('ml_delivery_tasks').select('order_id,delivered_at').eq('driver_id', driverId).eq('status', 4).order('delivered_at', { ascending: false }).limit(100).execute()
|
|
||||||
const orders = await supa.from('ml_orders').select('*').in('id', tasks.map(t=>t.order_id)).execute()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 分页与筛选
|
|
||||||
- 使用后端分页(`page/size`),前端仅负责渲染和“加载更多”。
|
|
||||||
- 支持按日期区间和商家名模糊搜索。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
- 当从本地合并刚完成订单时,去重逻辑必不可少(按 `order_no` 或 `id`)。
|
|
||||||
- 对于大量历史数据,应依赖后端支持归档与按需加载。错误/异常应有兜底 UI(空状态/重试按钮)。
|
|
||||||
|
|
||||||
46
pages/mall/delivery/doc/需求文档(现用)/README.md
Normal file
46
pages/mall/delivery/doc/需求文档(现用)/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 消息推送与物流发货后台 —— 需求与架构文档总览
|
||||||
|
|
||||||
|
> **更新日期**:2026年03月16日
|
||||||
|
> **核心职责**:管理商城发货物流对接、Webhook状态更新接受、基于消息队列模型的异步应用内推送(UniPush 2.0)。
|
||||||
|
|
||||||
|
本目录包含了本系统从“接收物流消息”到“完成终端设备 APP 推送”的全核心链路设计与需求规范。我们采用了**免 Redis 的高可靠去中心化架构(基于 PostgreSQL Transactional Outbox 模式)**。
|
||||||
|
|
||||||
|
## 🎯 必读核心文档(从 0 到 1 快速理解本套系统)
|
||||||
|
|
||||||
|
1. 📂 **数据库表结构设计(全系统基石)**
|
||||||
|
- [消息推送后台_数据库表结构需求文档.md](./db/消息推送后台_数据库表结构需求文档.md) *(核心!详述了从快递节点到通知排队流转经过的 5 张关键表)*
|
||||||
|
2. 🔄 **数据流向与演进架构**
|
||||||
|
- 见 `server/消息推送文档/DELIVERY_NOTIFICATION_DATA_FLOW.md` *(全局架构数据泳道流转图与为什么不使用 Redis 的技术选型解说)*
|
||||||
|
3. 📦 **业务推送机制文档**
|
||||||
|
- [物流消息推送方案_用户端与商家端.md](物流消息推送方案_用户端与商家端.md) *(阐述推送业务交互形态及场景)*
|
||||||
|
- [推送与设备需求文档_含建表附录.md](推送与设备需求文档_含建表附录.md) *(阐述设备绑定及队列下发业务目标)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 目录体系详解
|
||||||
|
|
||||||
|
### 1. 核心架构与数据库执行脚本 (`db/` 目录)
|
||||||
|
如果需要在全新环境部署此推送系统,请**按顺序执行**以下表结构 SQL 迁移脚本:
|
||||||
|
- `express_tracking_platform_upgrade.sql`: 运单主表、源事件表(`platform_express_event_raw`/`tracking_events`)的创建。
|
||||||
|
- `20260224_add_push_devices_and_notifications.sql`: 客户端推送标记路由表(`push_devices`)与最终推送记录大表(`express_notifications`)创建。
|
||||||
|
- `20260309_add_notify_queue_and_trigger.sql`: **极度重要!**创建异步缓冲外箱表 `notify_queue` 和连接事件与处理器的 PostgreSQL 防丢触发器 `event_to_queue_trigger`。
|
||||||
|
- `20260309_add_express_notifications_send_status.sql`: 添加投递状态控制。
|
||||||
|
- `20260310_fix_express_notifications_on_conflict_message_id.sql`: 去重逻辑修复。
|
||||||
|
|
||||||
|
### 2. 前端与联调试配规范文档
|
||||||
|
本块文档聚焦于多端(客户端 UniApp 与 管理后台端)关于物流模块的展现与对接规范:
|
||||||
|
- **客户端获取 CID 核心技术栈**:[uni-push2_安卓联调与取CID说明.md](uni-push2_安卓联调与取CID说明.md)(指导客户端童鞋如果提取各厂手机独立推送凭证)
|
||||||
|
- **后台页面结构**:[后台页面设计说明.md](后台页面设计说明.md)
|
||||||
|
- **数据流与展示字段**:[前端字段清单.md](前端字段清单.md)
|
||||||
|
- **前后端快递状态映射**:[状态映射表.md](状态映射表.md) (如:快递参数如何转为本系统枚举类型代码)
|
||||||
|
|
||||||
|
### 3. 测试与沙盘联调 (Mock 专属)
|
||||||
|
因接入第三方(如快递100)需签约,本系统也内置了一套模拟联调能力。
|
||||||
|
- [接口规范.md](接口规范.md)
|
||||||
|
- SQL 数据灌入脚本:`simulate_third_party_to_db.sql` / `seed_platform_express_test_data.sql` / `express_tracking_mock_platform.sql`
|
||||||
|
|
||||||
|
### 4. (历史/备份)早期草案与建议档案
|
||||||
|
以下文档作为架构迭代的历史依据:
|
||||||
|
- `推送与设备需求文档.md`(原始未涵盖表结构的草案)
|
||||||
|
- `配送模块需求文档.md`(旧三通一达体系梳理)
|
||||||
|
- `生产表说明_platform_express.md` / `数据库对比与修改建议.md` / `缺口与待补充清单.md`
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- 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,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
next_attempt_at TIMESTAMP WITH TIME ZONE 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 做幂等(外部系统/队列),则建立唯一索引
|
||||||
|
-- 注意:这里不能使用“部分唯一索引(WHERE message_id IS NOT NULL)”,
|
||||||
|
-- 否则 PostgREST 的 upsert `?on_conflict=message_id` 会触发 42P10:
|
||||||
|
-- "there is no unique or exclusion constraint matching the ON CONFLICT specification"
|
||||||
|
-- 普通 UNIQUE INDEX 仍允许多个 NULL(符合历史兼容)。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||||
|
ON public.express_notifications(message_id);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 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 天未上报)。
|
||||||
|
-- =====================================================================================
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- Add send_status to express_notifications
|
||||||
|
--
|
||||||
|
-- 目的:区分“物流状态(status_code)”与“投递处理状态(send_status)”。
|
||||||
|
-- - status_code:业务/物流状态(SHIPPED/OUT_FOR_DELIVERY/...)
|
||||||
|
-- - send_status:投递状态(null=待发送, processing, retrying, success, failed, no-targets)
|
||||||
|
--
|
||||||
|
-- 创建日期:2026-03-09
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.express_notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS send_status VARCHAR(32) NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_express_notifications_send_status
|
||||||
|
ON public.express_notifications(send_status);
|
||||||
|
|
||||||
|
-- 兼容旧实现:历史上 push-server consumer 使用 status_code 存投递状态。
|
||||||
|
-- 迁移后 consumer 改读写 send_status,为避免把旧的 success/failed 等记录当成 pending 再次推送,做一次安全回填。
|
||||||
|
UPDATE public.express_notifications
|
||||||
|
SET send_status = status_code
|
||||||
|
WHERE send_status IS NULL
|
||||||
|
AND status_code IN ('processing', 'retrying', 'success', 'failed', 'no-targets');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- notify_queue + trigger: platform_express_tracking_events -> notify_queue
|
||||||
|
--
|
||||||
|
-- 目的:把“轨迹事件入库”和“消息生成/推送”解耦。
|
||||||
|
-- - Webhook/轮询/手工写入 tracking_events 后,由触发器把关键事件入队到 notify_queue。
|
||||||
|
-- - 常驻 worker 消费 notify_queue,生成 express_notifications(消息中心/推送任务)。
|
||||||
|
-- - push-server consumer 轮询 express_notifications 并调用 CLOUD_FUNC_URL 进行实际下发。
|
||||||
|
--
|
||||||
|
-- 创建日期:2026-03-09
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 队列表:仅做“轻量入队”,避免触发器做外部 IO
|
||||||
|
CREATE TABLE IF NOT EXISTS public.notify_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
waybill_id UUID NOT NULL,
|
||||||
|
carrier VARCHAR(32) NULL,
|
||||||
|
tracking_no VARCHAR(64) NULL,
|
||||||
|
|
||||||
|
event_id VARCHAR(128) NULL,
|
||||||
|
status_code VARCHAR(32) NOT NULL,
|
||||||
|
event_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
event_text TEXT NULL,
|
||||||
|
source VARCHAR(16) NULL,
|
||||||
|
|
||||||
|
-- 与 platform_express_tracking_events 对齐的幂等键
|
||||||
|
dedupe_key VARCHAR(256) NOT NULL,
|
||||||
|
raw_payload JSONB NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMP WITH TIME ZONE NULL,
|
||||||
|
process_status VARCHAR(32) NULL,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT uk_notify_queue_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notify_queue_processed_at ON public.notify_queue(processed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notify_queue_created_at ON public.notify_queue(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notify_queue_status_code ON public.notify_queue(status_code);
|
||||||
|
|
||||||
|
-- 触发器函数:入队关键状态事件
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_new_tracking_event()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $func$
|
||||||
|
DECLARE
|
||||||
|
should_enqueue BOOLEAN := FALSE;
|
||||||
|
BEGIN
|
||||||
|
-- 推送策略(MVP):只对关键状态入队
|
||||||
|
IF NEW.status_code IN ('SHIPPED','OUT_FOR_DELIVERY','READY_FOR_PICKUP','DELIVERED','EXCEPTION','RETURNED') THEN
|
||||||
|
should_enqueue := TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF should_enqueue THEN
|
||||||
|
INSERT INTO public.notify_queue(
|
||||||
|
waybill_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
event_id,
|
||||||
|
status_code,
|
||||||
|
event_time,
|
||||||
|
event_text,
|
||||||
|
source,
|
||||||
|
dedupe_key,
|
||||||
|
raw_payload
|
||||||
|
) VALUES (
|
||||||
|
NEW.waybill_id,
|
||||||
|
NEW.carrier,
|
||||||
|
NEW.tracking_no,
|
||||||
|
NEW.event_id,
|
||||||
|
NEW.status_code,
|
||||||
|
NEW.event_time,
|
||||||
|
NEW.event_text,
|
||||||
|
NEW.source,
|
||||||
|
NEW.dedupe_key,
|
||||||
|
NEW.raw_payload
|
||||||
|
)
|
||||||
|
ON CONFLICT (waybill_id, dedupe_key) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
-- 触发器:tracking_events 写入后入队
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_notify_new_tracking_event'
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER trigger_notify_new_tracking_event
|
||||||
|
AFTER INSERT ON public.platform_express_tracking_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.notify_new_tracking_event();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- Fix express_notifications upsert conflict target (message_id)
|
||||||
|
-- 目的:修复 notify-worker / push-server 在 upsert 时遇到的 42P10:
|
||||||
|
-- "there is no unique or exclusion constraint matching the ON CONFLICT specification"
|
||||||
|
-- 原因:历史迁移可能创建了部分唯一索引(WHERE message_id IS NOT NULL),
|
||||||
|
-- PostgREST 的 upsert `?on_conflict=message_id` 无法匹配该索引。
|
||||||
|
-- 方案:改为普通 UNIQUE INDEX (message_id)。Postgres UNIQUE 允许多条 NULL,兼容旧数据。
|
||||||
|
-- 创建日期:2026-03-10
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) 预检查:是否存在重复的非空 message_id(若存在,创建唯一索引会失败)
|
||||||
|
-- 如有返回结果,请先人工去重后再继续执行后续语句。
|
||||||
|
-- 示例去重策略:保留最新 created_at,其它行将 message_id 置为 NULL 或删除重复行。
|
||||||
|
--
|
||||||
|
-- SELECT message_id, COUNT(*)
|
||||||
|
-- FROM public.express_notifications
|
||||||
|
-- WHERE message_id IS NOT NULL
|
||||||
|
-- GROUP BY message_id
|
||||||
|
-- HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- 2) 删除历史“部分唯一索引”(如果存在)
|
||||||
|
DROP INDEX IF EXISTS public.ux_express_notifications_message_id;
|
||||||
|
|
||||||
|
-- 3) 创建普通唯一索引,让 `on_conflict=message_id` 正常工作
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||||
|
ON public.express_notifications(message_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- 第三方快递轨迹(平台侧)表结构升级 (PostgreSQL + Supabase)
|
||||||
|
-- 用途:
|
||||||
|
-- - 引入第三方承运方运单与轨迹事件的统一入库模型
|
||||||
|
-- - 支撑商家端/用户端/平台后台的同源轨迹展示
|
||||||
|
-- 说明:
|
||||||
|
-- - 仅创建 platform 侧三张表:platform_express_waybills / platform_express_tracking_events / platform_express_event_raw
|
||||||
|
-- - 不包含 mock_* 测试表
|
||||||
|
-- - 不涉及自营骑手表(ml_delivery_*)
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 依赖扩展(项目主库已使用 uuid-ossp;这里重复声明是安全的)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||||
|
|
||||||
|
-- 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. 平台侧(platform):统一轨迹模型入库与查询
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- 运单主表:按 (carrier, tracking_no) 唯一
|
||||||
|
CREATE TABLE IF NOT EXISTS public.platform_express_waybills (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- 关联订单(推荐关联主键;order_no 作为展示/兜底)
|
||||||
|
order_id UUID NULL REFERENCES public.ml_orders(id) ON DELETE SET NULL,
|
||||||
|
order_no VARCHAR(64) NULL,
|
||||||
|
|
||||||
|
carrier VARCHAR(32) NOT NULL, -- YUNDA/YTO/ZTO/STO/KDN...
|
||||||
|
tracking_no VARCHAR(64) NOT NULL,
|
||||||
|
source VARCHAR(16) NOT NULL DEFAULT 'mock', -- mock/carrier/aggregator
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- updated_at 触发器
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger
|
||||||
|
WHERE tgname = 'trigger_platform_express_waybills_updated_at'
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER trigger_platform_express_waybills_updated_at
|
||||||
|
BEFORE UPDATE ON public.platform_express_waybills
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 索引(按订单查运单是主查询路径)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_order_id
|
||||||
|
ON public.platform_express_waybills(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_order_no
|
||||||
|
ON public.platform_express_waybills(order_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_tracking_no
|
||||||
|
ON public.platform_express_waybills(tracking_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_status
|
||||||
|
ON public.platform_express_waybills(current_status_code);
|
||||||
|
|
||||||
|
|
||||||
|
-- 轨迹事件表:前端时间线/告警/统计的主数据来源
|
||||||
|
CREATE TABLE IF NOT EXISTS public.platform_express_tracking_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
waybill_id UUID NOT NULL REFERENCES public.platform_express_waybills(id) ON DELETE CASCADE,
|
||||||
|
carrier VARCHAR(32) NOT NULL,
|
||||||
|
tracking_no VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
|
-- 第三方事件唯一标识(可能缺失)
|
||||||
|
event_id VARCHAR(128) NULL,
|
||||||
|
|
||||||
|
event_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
event_code VARCHAR(64) NOT NULL,
|
||||||
|
event_text TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- 平台统一状态
|
||||||
|
status_code VARCHAR(32) NOT NULL,
|
||||||
|
|
||||||
|
node_name VARCHAR(128) NULL,
|
||||||
|
location TEXT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
|
||||||
|
evidence_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- 原始回文(用于审计/排障)
|
||||||
|
raw_payload JSONB NULL,
|
||||||
|
|
||||||
|
-- 接收侧信息
|
||||||
|
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
source VARCHAR(16) NOT NULL DEFAULT 'webhook', -- webhook/poll/manual
|
||||||
|
|
||||||
|
-- 幂等去重键:优先 event_id;缺失时用 tracking_no+event_code+event_time(+可选字段) 构造
|
||||||
|
dedupe_key VARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_platform_express_event_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_events_waybill_time
|
||||||
|
ON public.platform_express_tracking_events(waybill_id, event_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_events_tracking_time
|
||||||
|
ON public.platform_express_tracking_events(tracking_no, event_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_events_status
|
||||||
|
ON public.platform_express_tracking_events(status_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_events_received_at
|
||||||
|
ON public.platform_express_tracking_events(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS gin_platform_express_events_raw_payload
|
||||||
|
ON public.platform_express_tracking_events USING gin (raw_payload);
|
||||||
|
|
||||||
|
|
||||||
|
-- 原始接收表:记录每一次 webhook/轮询原文、验签结果与解析错误
|
||||||
|
CREATE TABLE IF NOT EXISTS public.platform_express_event_raw (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
|
||||||
|
|
||||||
|
-- 请求侧标识
|
||||||
|
client_id VARCHAR(64) NULL,
|
||||||
|
carrier VARCHAR(32) NULL,
|
||||||
|
tracking_no VARCHAR(64) NULL,
|
||||||
|
|
||||||
|
-- 安全审计
|
||||||
|
signature_valid BOOLEAN NULL,
|
||||||
|
signature TEXT NULL,
|
||||||
|
ts_header TEXT NULL,
|
||||||
|
|
||||||
|
request_id VARCHAR(64) NULL,
|
||||||
|
remote_ip INET NULL,
|
||||||
|
headers JSONB NULL,
|
||||||
|
|
||||||
|
body JSONB NULL,
|
||||||
|
parse_error TEXT NULL,
|
||||||
|
|
||||||
|
-- 去重辅助(可选):用于识别完全重复的原始请求
|
||||||
|
dedupe_key VARCHAR(256) NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_received_at
|
||||||
|
ON public.platform_express_event_raw(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_tracking_no
|
||||||
|
ON public.platform_express_event_raw(tracking_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_signature_valid
|
||||||
|
ON public.platform_express_event_raw(signature_valid);
|
||||||
|
CREATE INDEX IF NOT EXISTS gin_platform_express_raw_body
|
||||||
|
ON public.platform_express_event_raw USING gin (body);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
101
pages/mall/delivery/doc/需求文档(现用)/db/消息推送后台_数据库表结构需求文档.md
Normal file
101
pages/mall/delivery/doc/需求文档(现用)/db/消息推送后台_数据库表结构需求文档.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 消息推送后台 —— 核心数据库表结构需求文档
|
||||||
|
|
||||||
|
> **创建日期**:2026年03月16日
|
||||||
|
> **模块归属**:商城发货与物流推送后台
|
||||||
|
> **涉及架构**:Node.js Worker + Supabase (PostgreSQL) + UniPush 2.0
|
||||||
|
|
||||||
|
为支撑高可用、防丢失、可追溯的异步消息推送架构,本消息推送系统在底层(Supabase/PostgreSQL)摒弃了传统的 Redis 中间件依赖,直接利用关系型数据库的横表与触发器机制(Transactional Outbox 事务外箱模式)完成了高并发状态下的物流事件缓冲与分发。
|
||||||
|
|
||||||
|
本需求文档详细列出了参与推送闭环的 **5 张核心数据表** 的设计规范、字段说明与职责定位,是后端/DBA 开发与日常客诉运维排障的唯一真理依据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整体库表流转全景
|
||||||
|
消息在数据库中的物理游走路径为:
|
||||||
|
**`平台接收原生钩子`** $\rightarrow$ **`轨迹业务表入库`** $\rightarrow$ (触发器) $\rightarrow$ **`任务队列缓冲表`** $\rightarrow$ (合并订单/用户数据) $\rightarrow$ **`最终下发通知表`** $\rightarrow$ **`匹配设备表CID推送`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、 原始通信日志表 (`platform_express_event_raw`)
|
||||||
|
|
||||||
|
### 1. 表职责方向
|
||||||
|
保存第一手的物流商(如快递100)Webhook 推送过来的原生 JSON 报文。不做任何业务过滤。
|
||||||
|
- **核心价值**:排查“某单是不是底下的快递公司就没给我们发回调?”的第一现场。如果这里没有记录,绝不可能是本系统的 Bug。
|
||||||
|
|
||||||
|
### 2. 核心字段设计 (逻辑概述)
|
||||||
|
* `id` (UUID): 唯一自增主键。
|
||||||
|
* `raw_payload` (JSONB): 快递100推过来的纯原生报文。
|
||||||
|
* `created_at` (Timestamp): 平台接收到外网请求的确切服务器时间。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 规范化轨迹主表 (`platform_express_tracking_events` / `waybills`)
|
||||||
|
|
||||||
|
### 1. 表职责方向
|
||||||
|
用于商城订单域查询使用的结构化物流跟踪事件表。接收服务将 `event_raw` 里的 JSON 解包后,转化为本商城统一的枚举状态。
|
||||||
|
- **机制特点**:这张表在发生 `INSERT` (即产生新轨迹节点) 时,会触发 PostgreSQL 内置触发器 `event_to_queue_trigger`。
|
||||||
|
|
||||||
|
### 2. 核心字段要求
|
||||||
|
* `waybill_id` (UUID): 关联的运单主键。
|
||||||
|
* `status_code` (VARCHAR): 规范化的签收/派件枚举状态码。
|
||||||
|
* `event_text` (TEXT): 具体的物流节点描述文本(例:“北京市【朝阳区】,您的快递已由门卫代签收”)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、 异步缓冲队列表 (`notify_queue`)
|
||||||
|
|
||||||
|
### 1. 表职责方向
|
||||||
|
承接业务量暴增时的**削峰填谷**与**断电防丢**任务(本质是实现基于 DB 的 Message Queue)。
|
||||||
|
- **流程规范**:触发器把满足条件(如需要给用户弹推送的节点)的事件主键,极速插入本表待命。后台的 `notify-worker.js` (消费者) 轮询查表消费。
|
||||||
|
|
||||||
|
### 2. 核心字段定义
|
||||||
|
* `id` (UUID): 队列任务唯一标识。
|
||||||
|
* `waybill_id` (UUID): 运单号,Worker 读取后利用它去关联查询出订单里的 `user_id` 和商品信息。
|
||||||
|
* `status_code` (VARCHAR): 通知级别状态。
|
||||||
|
* **`dedupe_key` (VARCHAR)**: 防重幂等键。防止因为相同事件重复回调产生两条推送。
|
||||||
|
* **`process_status` (VARCHAR)**: 最核心的状态机!
|
||||||
|
* `pending`: 等待 Node.js Worker 消费。
|
||||||
|
* `processing`: Worker 消费中(乐观锁占用)。
|
||||||
|
* `processed`: 拼接业务数据完成,已成功放入推送大表,完美结束。
|
||||||
|
* `failed`: 服务异常或订单找不到,抛弃处理。
|
||||||
|
* `last_error` (TEXT): 消费报错时的堆栈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、 最终下发通知表 (`express_notifications`)
|
||||||
|
|
||||||
|
### 1. 表职责方向
|
||||||
|
面向客户端推送与“站内信展示”的实体记录表。这里存放的是已经完成业务数据组装(谁买的、买的啥、什么物流状态),只需调用第三方厂商接口往外发的纯粹“信件”内容。
|
||||||
|
- **流程规范**:`push-server.js` 轮询此表进行真实推送。
|
||||||
|
|
||||||
|
### 2. 核心字段定义
|
||||||
|
* `id` (UUID): 消息的主键ID。
|
||||||
|
* `recipient_id` (UUID): 接收用户的标识 (商城买家ID)。
|
||||||
|
* `event_text_safe` (TEXT): 已经组装好且脱敏的安全话术(例如:“[张*],您的Nike球鞋正在派送中”)。
|
||||||
|
* **`send_status / push_status` (VARCHAR)**: 推送服务通道状态机。
|
||||||
|
* `pending`: 已产生信件,排队等待网络 POST 请发给 UniPush 云函数。
|
||||||
|
* `delivered`: 云函数返回 200,且厂家确认通道收录。
|
||||||
|
* `failed`: 网络异常或设备CID已注销失效。
|
||||||
|
* `provider_response` (JSONB): 如果推送失败,此处会存储华为/小米等厂商或推服务产生的物理失败原因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、 设备凭证绑定表 (`push_devices`)
|
||||||
|
|
||||||
|
### 1. 表职责方向
|
||||||
|
提供从“商城系统 `user_id`” 到“手机厂家原生通道标识 `cid`” 的 KV 路由映射能力。
|
||||||
|
- **业务场景**:用户 App 在启动或大版本更新时,前端会通过 `uni.getPushClientId` 获取本设备的厂商识别码,通过接口上报并 UPSERT 写入此表。
|
||||||
|
|
||||||
|
### 2. 核心字段定义
|
||||||
|
* `id` (UUID): 映射记录主键。
|
||||||
|
* `user_id` (UUID): 系统业务逻辑里的用户身份标识。
|
||||||
|
* **`cid` (VARCHAR)**: UniPush 2.0 分配在设备当前生命周期的绝对推送识别码(至关重要,一旦清空缓存/重装系统可能会变动,必须持续覆盖更新)。
|
||||||
|
* `updated_at` (Timestamp): 用以判断该设备CID的活跃新鲜度。如果一个CID超过1年未更新导致 `push_status` 频繁 `failed`,后台可将其标记为离线沉默用户放弃网络调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:业务表拓展准则 (Guidelines)
|
||||||
|
如果未来需要将这套机制从物流推送拓展至**“拼团成功通知”**或**“特价秒杀通知”**,只需要:
|
||||||
|
1. 复用 `express_notifications` 表,或者建立同层级类似的 `system_notifications` 表,保留 `push_status` 和 `recipient_id`。
|
||||||
|
2. 复用 `push_devices` 路由查找。
|
||||||
|
3. 把源头触发器挂载到新的交易表中即可。此五表闭环具备极佳的**水平扩展性**与**极低的中间件运维成本**。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- RPC: notify-worker safe recipients lookup (RLS-safe)
|
||||||
|
--
|
||||||
|
-- 背景:
|
||||||
|
-- - public.ml_orders 已开启 RLS,PostgREST 在未携带可解码 JWT 时,auth.uid() 为 NULL,
|
||||||
|
-- 直接 SELECT 会被策略过滤为 0 行,导致 notify-worker 报 “order not found for waybill”。
|
||||||
|
-- - 在一些自托管场景中,Authorization: Bearer <service_role JWT> 可能因 JWT_SECRET 不一致被 PostgREST 拒绝(401 PGRST301)。
|
||||||
|
--
|
||||||
|
-- 方案:
|
||||||
|
-- - 提供 SECURITY DEFINER 的 RPC:只返回订单的收件人映射(user_id / merchant_id)。
|
||||||
|
-- - 通过请求头 x-notify-worker-token 做显式鉴权(避免把表全局 SELECT 放开)。
|
||||||
|
--
|
||||||
|
-- 使用:
|
||||||
|
-- - notify-worker 调用 POST /rest/v1/rpc/notify_get_order_recipients
|
||||||
|
-- 并携带 header: x-notify-worker-token: <NOTIFY_WORKER_RPC_TOKEN>
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_get_order_recipients(
|
||||||
|
p_order_id UUID DEFAULT NULL,
|
||||||
|
p_order_no TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
order_no VARCHAR,
|
||||||
|
user_id UUID,
|
||||||
|
merchant_id UUID
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $func$
|
||||||
|
DECLARE
|
||||||
|
headers_json JSON;
|
||||||
|
token TEXT;
|
||||||
|
expected_token TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- 1) 读取请求头 token(PostgREST 会把 headers 放入 GUC request.headers)
|
||||||
|
expected_token := current_setting('app.notify_worker_token', true);
|
||||||
|
headers_json := NULLIF(current_setting('request.headers', true), '')::json;
|
||||||
|
IF headers_json IS NOT NULL THEN
|
||||||
|
token := headers_json->>'x-notify-worker-token';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF expected_token IS NULL OR expected_token = '' THEN
|
||||||
|
RAISE EXCEPTION 'server misconfigured: app.notify_worker_token is not set';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF token IS NULL OR token <> expected_token THEN
|
||||||
|
RAISE EXCEPTION 'permission denied: invalid x-notify-worker-token';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2) 参数校验
|
||||||
|
IF (p_order_id IS NULL OR p_order_id::text = '') AND (p_order_no IS NULL OR btrim(p_order_no) = '') THEN
|
||||||
|
RAISE EXCEPTION 'p_order_id or p_order_no must be provided';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3) 返回映射(SECURITY DEFINER 可绕过 RLS;只返回最小必要字段)
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT o.id, o.order_no, o.user_id, o.merchant_id
|
||||||
|
FROM public.ml_orders o
|
||||||
|
WHERE (p_order_id IS NOT NULL AND o.id = p_order_id)
|
||||||
|
OR (p_order_no IS NOT NULL AND o.order_no = p_order_no)
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
-- 默认收紧:撤销 PUBLIC,按需授予 anon/authenticated/service_role 执行权限。
|
||||||
|
REVOKE ALL ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) FROM PUBLIC;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
||||||
|
GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO anon;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
||||||
|
GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO authenticated;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
||||||
|
GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO service_role;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
-- 物流轨迹(第三方/Mock)数据库设计(PostgreSQL / Supabase 兼容)
|
||||||
|
-- 重要说明(生产环境请勿直接执行本文件):
|
||||||
|
-- - 本文件包含平台侧表 + mock_* 测试表(用于联调、故障注入与回放)。
|
||||||
|
-- - 生产环境请执行迁移脚本:mall_sql/migrations/express_tracking_platform_upgrade.sql
|
||||||
|
-- 该脚本仅创建平台侧 platform_express_* 三张表,并与主库 UUID/updated_at 规范对齐。
|
||||||
|
-- 目标:
|
||||||
|
-- 1) 平台侧:统一接收不同第三方事件 -> 幂等去重/乱序入库 -> 给前端提供稳定时间线
|
||||||
|
-- 2) Mock 承运方侧:可持久化运单/事件/场景/推送日志,支持故障注入与回放
|
||||||
|
-- 说明:本文件仅提供表结构与索引建议;RLS/权限策略按项目实际补充。
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Supabase / Postgres 常用扩展
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- 通用 updated_at 维护函数(如项目已有同名函数,可忽略重复定义)
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_updated_at()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- A. 平台侧(platform):统一轨迹模型入库与查询
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_express_waybills (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- 可选:若平台订单已存在,可关联
|
||||||
|
order_id uuid NULL,
|
||||||
|
order_no varchar(64) NULL,
|
||||||
|
|
||||||
|
carrier varchar(32) NOT NULL, -- YUNDA/YTO/ZTO/STO/KDN...
|
||||||
|
tracking_no varchar(64) NOT NULL,
|
||||||
|
source varchar(16) NOT NULL DEFAULT 'mock', -- mock/carrier/aggregator
|
||||||
|
|
||||||
|
current_status_code varchar(32) NOT NULL DEFAULT 'SHIPPED',
|
||||||
|
current_status_text text NULL,
|
||||||
|
|
||||||
|
eta timestamptz NULL,
|
||||||
|
last_synced_at timestamptz NULL,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_platform_waybill UNIQUE (carrier, tracking_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_platform_waybills_updated_at
|
||||||
|
BEFORE UPDATE ON platform_express_waybills
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_waybills_order_no ON platform_express_waybills(order_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_waybills_tracking_no ON platform_express_waybills(tracking_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_waybills_status ON platform_express_waybills(current_status_code);
|
||||||
|
|
||||||
|
|
||||||
|
-- 统一事件表:前端时间线/告警/统计的主数据来源
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_express_tracking_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
waybill_id uuid NOT NULL REFERENCES platform_express_waybills(id) ON DELETE CASCADE,
|
||||||
|
carrier varchar(32) NOT NULL,
|
||||||
|
tracking_no varchar(64) NOT NULL,
|
||||||
|
|
||||||
|
-- 第三方事件唯一标识(可能缺失)
|
||||||
|
event_id varchar(128) NULL,
|
||||||
|
|
||||||
|
event_time timestamptz NOT NULL,
|
||||||
|
event_code varchar(64) NOT NULL,
|
||||||
|
event_text text NOT NULL,
|
||||||
|
|
||||||
|
status_code varchar(32) NOT NULL, -- 平台统一状态
|
||||||
|
|
||||||
|
node_name varchar(128) NULL,
|
||||||
|
location text NULL,
|
||||||
|
description text NULL,
|
||||||
|
|
||||||
|
evidence_urls jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- 原始回文(用于审计/排障;如担心体积可移到 raw 表或只存引用)
|
||||||
|
raw_payload jsonb NULL,
|
||||||
|
|
||||||
|
-- 接收侧信息
|
||||||
|
received_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
source varchar(16) NOT NULL DEFAULT 'webhook', -- webhook/poll/manual
|
||||||
|
|
||||||
|
-- 幂等去重键:优先 event_id;缺失时用 tracking_no+event_code+event_time(+可选字段) 构造
|
||||||
|
dedupe_key varchar(256) NOT NULL,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_platform_event_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_events_waybill_time ON platform_express_tracking_events(waybill_id, event_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_events_tracking_time ON platform_express_tracking_events(tracking_no, event_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_events_status ON platform_express_tracking_events(status_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_events_received_at ON platform_express_tracking_events(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS gin_platform_events_raw_payload ON platform_express_tracking_events USING gin (raw_payload);
|
||||||
|
|
||||||
|
|
||||||
|
-- 原始接收表:记录每一次 webhook/轮询原文、验签结果与解析错误
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_express_event_raw (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
received_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
source varchar(16) NOT NULL DEFAULT 'webhook',
|
||||||
|
|
||||||
|
-- 请求侧标识
|
||||||
|
client_id varchar(64) NULL,
|
||||||
|
carrier varchar(32) NULL,
|
||||||
|
tracking_no varchar(64) NULL,
|
||||||
|
|
||||||
|
-- 安全审计
|
||||||
|
signature_valid boolean NULL,
|
||||||
|
signature text NULL,
|
||||||
|
ts_header text NULL,
|
||||||
|
|
||||||
|
request_id varchar(64) NULL,
|
||||||
|
remote_ip inet NULL,
|
||||||
|
headers jsonb NULL,
|
||||||
|
|
||||||
|
body jsonb NULL,
|
||||||
|
parse_error text NULL,
|
||||||
|
|
||||||
|
-- 去重辅助(可选):用于识别完全重复的原始请求
|
||||||
|
dedupe_key varchar(256) NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_raw_received_at ON platform_express_event_raw(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_raw_tracking_no ON platform_express_event_raw(tracking_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_raw_signature_valid ON platform_express_event_raw(signature_valid);
|
||||||
|
CREATE INDEX IF NOT EXISTS gin_platform_raw_body ON platform_express_event_raw USING gin (body);
|
||||||
|
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- B. Mock 承运方侧(mock):运单/事件/场景/推送日志
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_carrier_config (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name varchar(64) NOT NULL DEFAULT 'default',
|
||||||
|
target_webhook_url text NULL,
|
||||||
|
|
||||||
|
client_id varchar(64) NULL,
|
||||||
|
secret text NULL,
|
||||||
|
default_carrier varchar(32) NOT NULL DEFAULT 'YUNDA',
|
||||||
|
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mock_config_name UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_mock_carrier_config_updated_at
|
||||||
|
BEFORE UPDATE ON mock_carrier_config
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_waybills (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
carrier varchar(32) NOT NULL,
|
||||||
|
tracking_no varchar(64) NOT NULL,
|
||||||
|
order_no varchar(64) NULL,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mock_waybill UNIQUE (carrier, tracking_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_mock_waybills_updated_at
|
||||||
|
BEFORE UPDATE ON mock_waybills
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_tracking_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
waybill_id uuid NOT NULL REFERENCES mock_waybills(id) ON DELETE CASCADE,
|
||||||
|
carrier varchar(32) NOT NULL,
|
||||||
|
tracking_no varchar(64) NOT NULL,
|
||||||
|
|
||||||
|
event_id varchar(128) NULL,
|
||||||
|
event_time timestamptz NOT NULL,
|
||||||
|
event_code varchar(64) NOT NULL,
|
||||||
|
event_text text NOT NULL,
|
||||||
|
|
||||||
|
node_name varchar(128) NULL,
|
||||||
|
location text NULL,
|
||||||
|
description text NULL,
|
||||||
|
evidence_urls jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
raw_payload jsonb NULL,
|
||||||
|
|
||||||
|
-- 生成幂等键,便于 Mock 内部也能去重
|
||||||
|
dedupe_key varchar(256) NOT NULL,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mock_event_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mock_events_waybill_time ON mock_tracking_events(waybill_id, event_time);
|
||||||
|
|
||||||
|
|
||||||
|
-- 场景定义:标准签收/拒收退回/地址异常等
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_scenarios (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
scenario_code varchar(64) NOT NULL,
|
||||||
|
scenario_name varchar(128) NOT NULL,
|
||||||
|
description text NULL,
|
||||||
|
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mock_scenario_code UNIQUE (scenario_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_mock_scenarios_updated_at
|
||||||
|
BEFORE UPDATE ON mock_scenarios
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
|
||||||
|
-- 场景步骤:相对时间偏移 + 事件内容
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_scenario_steps (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
scenario_id uuid NOT NULL REFERENCES mock_scenarios(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
step_no int NOT NULL,
|
||||||
|
offset_seconds int NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
event_code varchar(64) NOT NULL,
|
||||||
|
event_text text NOT NULL,
|
||||||
|
status_code varchar(32) NULL,
|
||||||
|
|
||||||
|
node_name varchar(128) NULL,
|
||||||
|
location text NULL,
|
||||||
|
description text NULL,
|
||||||
|
evidence_urls jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mock_scenario_step UNIQUE (scenario_id, step_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 场景运行记录:便于复现/回放
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_scenario_runs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
waybill_id uuid NOT NULL REFERENCES mock_waybills(id) ON DELETE CASCADE,
|
||||||
|
scenario_id uuid NOT NULL REFERENCES mock_scenarios(id),
|
||||||
|
|
||||||
|
inject jsonb NOT NULL DEFAULT '{}'::jsonb, -- delay_ms/duplicate/out_of_order/bad_signature/timestamp_skew_seconds/drop_fields...
|
||||||
|
pushed boolean NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
started_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
finished_at timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mock_runs_waybill ON mock_scenario_runs(waybill_id, started_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- 推送日志:记录每次 push 的请求/响应摘要
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_push_logs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
waybill_id uuid NOT NULL REFERENCES mock_waybills(id) ON DELETE CASCADE,
|
||||||
|
event_id varchar(128) NULL,
|
||||||
|
dedupe_key varchar(256) NULL,
|
||||||
|
|
||||||
|
target_url text NULL,
|
||||||
|
request_headers jsonb NULL,
|
||||||
|
request_body jsonb NULL,
|
||||||
|
|
||||||
|
response_status int NULL,
|
||||||
|
response_body text NULL,
|
||||||
|
error_message text NULL,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mock_push_waybill ON mock_push_logs(waybill_id, created_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- 测试环境种子数据(仅写入 platform_express_* 三表)
|
||||||
|
--
|
||||||
|
-- 目的:在测试/预发环境使用“生产同款表结构”联调页面与接口,避免创建 mock_* 表。
|
||||||
|
-- 适用:已执行 express_tracking_platform_upgrade.sql 后。
|
||||||
|
--
|
||||||
|
-- 注意:
|
||||||
|
-- - 本文件会插入示例数据:大多数 tracking_no 以 TEST_ 前缀开头;另有 1 条 ORDER_PLACED 示例运单号为空。
|
||||||
|
-- - 如需清理,可执行文末的清理 SQL。
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 兼容旧版本种子数据:曾使用 TEST_YT_20260206_0010 作为“已下单”示例
|
||||||
|
-- 现要求:已下单运单号为空;因此先清理旧 tracking_no 相关记录,避免重复数据。
|
||||||
|
-- DELETE FROM public.platform_express_tracking_events WHERE tracking_no = 'TEST_YT_20260206_0010';
|
||||||
|
-- DELETE FROM public.platform_express_event_raw WHERE tracking_no = 'TEST_YT_20260206_0010';
|
||||||
|
-- DELETE FROM public.platform_express_waybills WHERE carrier = 'YTO' AND tracking_no = 'TEST_YT_20260206_0010' AND order_no = 'ORD_TEST_20260206010';
|
||||||
|
|
||||||
|
-- 1) 创建/更新示例运单(若已存在则跳过)
|
||||||
|
INSERT INTO public.platform_express_waybills (
|
||||||
|
order_id,
|
||||||
|
order_no,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
source,
|
||||||
|
current_status_code,
|
||||||
|
current_status_text,
|
||||||
|
eta,
|
||||||
|
last_synced_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(NULL, 'ORD_TEST_20260206001', 'YUNDA', 'TEST_YD_20260206_0001', 'mock', 'IN_TRANSIT', '运输中', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206002', 'YTO', 'TEST_YT_20260206_0002', 'mock', 'OUT_FOR_DELIVERY', '派送中', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206003', 'ZTO', 'TEST_ZT_20260206_0003', 'mock', 'DELIVERED', '已签收', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206004', 'STO', 'TEST_STO_20260206_0004', 'mock', 'EXCEPTION', '异常', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206005', 'SF', 'TEST_SF_20260206_0005', 'mock', 'IN_TRANSIT', '运输中', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206006', 'YUNDA', 'TEST_YD_20260206_0006', 'mock', 'SHIPPED', '已发货', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206007', 'YTO', 'TEST_YT_20260206_0007', 'mock', 'IN_TRANSIT', '运输中', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206008', 'ZTO', 'TEST_ZT_20260206_0008', 'mock', 'OUT_FOR_DELIVERY', '派送中', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206010', 'YTO', '', 'mock', 'ORDER_PLACED', '已下单', NULL, NOW()),
|
||||||
|
(NULL, 'ORD_TEST_20260206011', 'ZTO', 'TEST_ZT_20260206_0011', 'mock', 'READY_FOR_PICKUP', '待取件', NULL, NOW())
|
||||||
|
ON CONFLICT (carrier, tracking_no) DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
-- 2) 插入轨迹事件(幂等:按 (waybill_id, dedupe_key) 去重)
|
||||||
|
WITH w AS (
|
||||||
|
SELECT id, carrier, tracking_no
|
||||||
|
FROM public.platform_express_waybills
|
||||||
|
WHERE tracking_no IN (
|
||||||
|
'TEST_YD_20260206_0001',
|
||||||
|
'TEST_YT_20260206_0002',
|
||||||
|
'TEST_ZT_20260206_0003',
|
||||||
|
'TEST_STO_20260206_0004',
|
||||||
|
'TEST_SF_20260206_0005',
|
||||||
|
'TEST_YD_20260206_0006',
|
||||||
|
'TEST_YT_20260206_0007',
|
||||||
|
'TEST_ZT_20260206_0008',
|
||||||
|
'',
|
||||||
|
'TEST_ZT_20260206_0011'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
INSERT INTO public.platform_express_tracking_events (
|
||||||
|
waybill_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
event_id,
|
||||||
|
event_time,
|
||||||
|
event_code,
|
||||||
|
event_text,
|
||||||
|
status_code,
|
||||||
|
node_name,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
evidence_urls,
|
||||||
|
raw_payload,
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
SELECT * FROM (
|
||||||
|
-- 运单 1:运输中
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YD_20260206_0001') AS waybill_id,
|
||||||
|
'YUNDA' AS carrier,
|
||||||
|
'TEST_YD_20260206_0001' AS tracking_no,
|
||||||
|
'test_e_1001' AS event_id,
|
||||||
|
NOW() - INTERVAL '12 hours' AS event_time,
|
||||||
|
'PICKED' AS event_code,
|
||||||
|
'包裹已揽收' AS event_text,
|
||||||
|
'IN_TRANSIT' AS status_code,
|
||||||
|
'上海浦东集散中心' AS node_name,
|
||||||
|
'上海市 浦东新区' AS location,
|
||||||
|
NULL AS description,
|
||||||
|
'[]'::jsonb AS evidence_urls,
|
||||||
|
NULL::jsonb AS raw_payload,
|
||||||
|
NOW() AS received_at,
|
||||||
|
'poll' AS source,
|
||||||
|
'test_e_1001' AS dedupe_key
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YD_20260206_0001'),
|
||||||
|
'YUNDA',
|
||||||
|
'TEST_YD_20260206_0001',
|
||||||
|
'test_e_1002',
|
||||||
|
NOW() - INTERVAL '6 hours',
|
||||||
|
'TRANSIT',
|
||||||
|
'运输中(测试数据)',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'上海浦东集散中心',
|
||||||
|
'上海市 浦东新区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_1002'
|
||||||
|
|
||||||
|
-- 运单 2:派送中
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YT_20260206_0002'),
|
||||||
|
'YTO',
|
||||||
|
'TEST_YT_20260206_0002',
|
||||||
|
'test_e_2001',
|
||||||
|
NOW() - INTERVAL '8 hours',
|
||||||
|
'ARRIVAL',
|
||||||
|
'运输中',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'广州天河网点',
|
||||||
|
'广州市 天河区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_2001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YT_20260206_0002'),
|
||||||
|
'YTO',
|
||||||
|
'TEST_YT_20260206_0002',
|
||||||
|
'test_e_2002',
|
||||||
|
NOW() - INTERVAL '1 hours',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
'派送员正在派件(测试数据)',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
'广州天河网点',
|
||||||
|
'广州市 天河区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_2002'
|
||||||
|
|
||||||
|
-- 运单 3:已签收
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0003'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0003',
|
||||||
|
'test_e_3001',
|
||||||
|
NOW() - INTERVAL '2 days',
|
||||||
|
'PICKED',
|
||||||
|
'包裹已揽收',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'北京朝阳网点',
|
||||||
|
'北京市 朝阳区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_3001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0003'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0003',
|
||||||
|
'test_e_3002',
|
||||||
|
NOW() - INTERVAL '1 days',
|
||||||
|
'TRANSIT',
|
||||||
|
'快件运输中',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'北京朝阳网点',
|
||||||
|
'北京市 朝阳区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_3002'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0003'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0003',
|
||||||
|
'test_e_3003',
|
||||||
|
NOW() - INTERVAL '12 hours',
|
||||||
|
'DELIVERED',
|
||||||
|
'您的快件已签收(测试数据)',
|
||||||
|
'DELIVERED',
|
||||||
|
'北京朝阳网点',
|
||||||
|
'北京市 朝阳区',
|
||||||
|
NULL,
|
||||||
|
'["https://img-shop.gmugmu.com/mock/pod_sample.png"]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_3003'
|
||||||
|
|
||||||
|
-- 运单 4:异常
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_STO_20260206_0004'),
|
||||||
|
'STO',
|
||||||
|
'TEST_STO_20260206_0004',
|
||||||
|
'test_e_4001',
|
||||||
|
NOW() - INTERVAL '20 hours',
|
||||||
|
'PICKED',
|
||||||
|
'包裹已揽收',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'深圳南山网点',
|
||||||
|
'深圳市 南山区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_4001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_STO_20260206_0004'),
|
||||||
|
'STO',
|
||||||
|
'TEST_STO_20260206_0004',
|
||||||
|
'test_e_4002',
|
||||||
|
NOW() - INTERVAL '2 hours',
|
||||||
|
'EXCEPTION',
|
||||||
|
'【包裹异常】收件地址不详,等待处理(测试数据)',
|
||||||
|
'EXCEPTION',
|
||||||
|
'深圳南山网点',
|
||||||
|
'深圳市 南山区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_4002'
|
||||||
|
|
||||||
|
-- 运单 5:运输中(不区分中转/到达节点/分拨)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_SF_20260206_0005'),
|
||||||
|
'SF',
|
||||||
|
'TEST_SF_20260206_0005',
|
||||||
|
'test_e_5001',
|
||||||
|
NOW() - INTERVAL '10 hours',
|
||||||
|
'PICKED',
|
||||||
|
'包裹已揽收',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'南京江宁集散中心',
|
||||||
|
'南京市 江宁区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_5001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_SF_20260206_0005'),
|
||||||
|
'SF',
|
||||||
|
'TEST_SF_20260206_0005',
|
||||||
|
'test_e_5002',
|
||||||
|
NOW() - INTERVAL '30 minutes',
|
||||||
|
'ARRIVAL',
|
||||||
|
'运输中(测试数据)',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'南京江宁网点',
|
||||||
|
'南京市 江宁区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_5002'
|
||||||
|
|
||||||
|
-- 运单 6:已发货(待揽收)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YD_20260206_0006'),
|
||||||
|
'YUNDA',
|
||||||
|
'TEST_YD_20260206_0006',
|
||||||
|
'test_e_6001',
|
||||||
|
NOW() - INTERVAL '5 hours',
|
||||||
|
'SHIPPED',
|
||||||
|
'商家已发货,等待快递公司揽收(测试数据)',
|
||||||
|
'SHIPPED',
|
||||||
|
NULL,
|
||||||
|
'杭州市 余杭区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'manual',
|
||||||
|
'test_e_6001'
|
||||||
|
|
||||||
|
-- 运单 7:运输中
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YT_20260206_0007'),
|
||||||
|
'YTO',
|
||||||
|
'TEST_YT_20260206_0007',
|
||||||
|
'test_e_7001',
|
||||||
|
NOW() - INTERVAL '18 hours',
|
||||||
|
'PICKED',
|
||||||
|
'包裹已揽收',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'武汉江夏网点',
|
||||||
|
'武汉市 江夏区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_7001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_YT_20260206_0007'),
|
||||||
|
'YTO',
|
||||||
|
'TEST_YT_20260206_0007',
|
||||||
|
'test_e_7002',
|
||||||
|
NOW() - INTERVAL '7 hours',
|
||||||
|
'TRANSIT',
|
||||||
|
'运输中(测试数据)',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'武汉江夏网点',
|
||||||
|
'武汉市 江夏区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_7002'
|
||||||
|
|
||||||
|
-- 运单 8:派送中
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0008'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0008',
|
||||||
|
'test_e_8001',
|
||||||
|
NOW() - INTERVAL '9 hours',
|
||||||
|
'ARRIVAL',
|
||||||
|
'运输中(测试数据)',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'西安高新网点',
|
||||||
|
'西安市 雁塔区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_e_8001'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0008'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0008',
|
||||||
|
'test_e_8002',
|
||||||
|
NOW() - INTERVAL '20 minutes',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
'派送员正在派件(测试数据)',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
'西安高新网点',
|
||||||
|
'西安市 雁塔区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_8002'
|
||||||
|
|
||||||
|
-- 运单 9:已下单(平台生成事件示例;运单号为空)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = ''),
|
||||||
|
'YTO',
|
||||||
|
'',
|
||||||
|
'test_e_10001',
|
||||||
|
NOW() - INTERVAL '1 days',
|
||||||
|
'ORDER_PLACED',
|
||||||
|
'订单已下单(测试数据)',
|
||||||
|
'ORDER_PLACED',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'manual',
|
||||||
|
'test_e_10001'
|
||||||
|
|
||||||
|
-- 运单 10:待取件(到驿站/自提柜)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM w WHERE tracking_no = 'TEST_ZT_20260206_0011'),
|
||||||
|
'ZTO',
|
||||||
|
'TEST_ZT_20260206_0011',
|
||||||
|
'test_e_11001',
|
||||||
|
NOW() - INTERVAL '3 hours',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'快件已到达【杭州余杭菜鸟驿站】,请凭取件码取件(测试数据)',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'杭州余杭菜鸟驿站',
|
||||||
|
'杭州市 余杭区',
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL::jsonb,
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_e_11001'
|
||||||
|
) AS rows_to_insert
|
||||||
|
WHERE rows_to_insert.waybill_id IS NOT NULL
|
||||||
|
ON CONFLICT (waybill_id, dedupe_key) DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
-- 3) 可选:插入原始接收留痕(用于演示验签/排障界面)
|
||||||
|
INSERT INTO public.platform_express_event_raw (
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
client_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
signature_valid,
|
||||||
|
signature,
|
||||||
|
ts_header,
|
||||||
|
request_id,
|
||||||
|
remote_ip,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
parse_error,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_client',
|
||||||
|
'YTO',
|
||||||
|
'TEST_YT_20260206_0002',
|
||||||
|
TRUE,
|
||||||
|
'test-signature',
|
||||||
|
EXTRACT(EPOCH FROM NOW())::text,
|
||||||
|
'req_test_0001',
|
||||||
|
'127.0.0.1',
|
||||||
|
'{"content-type":"application/json"}'::jsonb,
|
||||||
|
'{"tracking_no":"TEST_YT_20260206_0002","status_code":"OUT_FOR_DELIVERY","event_text":"派送员正在派件(测试数据)"}'::jsonb,
|
||||||
|
NULL,
|
||||||
|
'raw_test_0001'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.platform_express_event_raw (
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
client_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
signature_valid,
|
||||||
|
signature,
|
||||||
|
ts_header,
|
||||||
|
request_id,
|
||||||
|
remote_ip,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
parse_error,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NOW(),
|
||||||
|
'poll',
|
||||||
|
'test_client',
|
||||||
|
'STO',
|
||||||
|
'TEST_STO_20260206_0004',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
EXTRACT(EPOCH FROM NOW())::text,
|
||||||
|
'req_test_0002',
|
||||||
|
'127.0.0.1',
|
||||||
|
'{"content-type":"application/json"}'::jsonb,
|
||||||
|
'{"tracking_no":"TEST_STO_20260206_0004","status_code":"EXCEPTION","event_text":"【包裹异常】收件地址不详,等待处理(测试数据)"}'::jsonb,
|
||||||
|
NULL,
|
||||||
|
'raw_test_0002'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ORDER_PLACED 示例:允许 tracking_no 为空,但 raw body 中保留 order_no 便于排障/审计筛选
|
||||||
|
INSERT INTO public.platform_express_event_raw (
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
client_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
signature_valid,
|
||||||
|
signature,
|
||||||
|
ts_header,
|
||||||
|
request_id,
|
||||||
|
remote_ip,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
parse_error,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NOW(),
|
||||||
|
'webhook',
|
||||||
|
'test_client',
|
||||||
|
'YTO',
|
||||||
|
NULL,
|
||||||
|
TRUE,
|
||||||
|
'test-signature',
|
||||||
|
EXTRACT(EPOCH FROM NOW())::text,
|
||||||
|
'req_test_0003',
|
||||||
|
'127.0.0.1',
|
||||||
|
'{"content-type":"application/json"}'::jsonb,
|
||||||
|
'{"order_no":"ORD_TEST_20260206010","tracking_no":"","status_code":"ORDER_PLACED","event_text":"已下单(测试数据)"}'::jsonb,
|
||||||
|
NULL,
|
||||||
|
'raw_test_0003'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- =====================================================================================
|
||||||
|
-- 清理(需要时手工执行)
|
||||||
|
-- =====================================================================================
|
||||||
|
-- BEGIN;
|
||||||
|
-- DELETE FROM public.platform_express_event_raw WHERE tracking_no LIKE 'TEST_%' OR (body->>'order_no') = 'ORD_TEST_20260206010';
|
||||||
|
-- DELETE FROM public.platform_express_tracking_events WHERE tracking_no LIKE 'TEST_%' OR status_code = 'ORDER_PLACED';
|
||||||
|
-- DELETE FROM public.platform_express_waybills WHERE tracking_no LIKE 'TEST_%' OR order_no = 'ORD_TEST_20260206010';
|
||||||
|
-- COMMIT;
|
||||||
199
pages/mall/delivery/doc/需求文档(现用)/simulate_third_party_to_db.sql
Normal file
199
pages/mall/delivery/doc/需求文档(现用)/simulate_third_party_to_db.sql
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
-- =====================================================================================
|
||||||
|
-- 模拟第三方“推送到平台”的数据(直接写入数据库)
|
||||||
|
--
|
||||||
|
-- 适用场景:
|
||||||
|
-- - 你暂时没有后端 webhook 接收服务,但希望在测试/预发环境快速伪造第三方推送效果。
|
||||||
|
-- - 通过写入:
|
||||||
|
-- 1) platform_express_event_raw (原始请求留痕/验签结果/排障信息)
|
||||||
|
-- 2) platform_express_tracking_events(解析后的轨迹事件,用于时间线展示)
|
||||||
|
-- 3) platform_express_waybills(运单摘要)
|
||||||
|
--
|
||||||
|
-- 注意:
|
||||||
|
-- - 这是“绕过后端解析与验签”的直写方案,仅用于测试/演示。
|
||||||
|
-- - 生产环境不建议这样做。
|
||||||
|
-- - 执行前请确保已跑过 express_tracking_platform_upgrade.sql 创建三表。
|
||||||
|
--
|
||||||
|
-- 使用方式:
|
||||||
|
-- - Supabase SQL Editor:把下面的【参数区】内容改成你要的值,然后整段执行。
|
||||||
|
-- =====================================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- 参数区(手工修改这里)
|
||||||
|
-- =========================
|
||||||
|
-- 承运商编码:建议与平台枚举一致(YTO/YUNDA/ZTO/STO...)
|
||||||
|
-- 运单号:建议用 TEST_ 前缀,便于清理
|
||||||
|
-- 事件码/状态码:按你的平台约定(IN_TRANSIT/OUT_FOR_DELIVERY/DELIVERED/EXCEPTION...)
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_order_no TEXT := 'ORD_TEST_20260206099';
|
||||||
|
v_carrier TEXT := 'YTO';
|
||||||
|
v_tracking_no TEXT := 'TEST_YT_20260206_0099';
|
||||||
|
|
||||||
|
v_event_id TEXT := NULL; -- 可为空
|
||||||
|
v_event_time TIMESTAMPTZ := NOW();
|
||||||
|
v_event_code TEXT := 'OUT_FOR_DELIVERY';
|
||||||
|
v_event_text TEXT := '派送员正在派件(伪造推送,直写数据库)';
|
||||||
|
v_status_code TEXT := 'OUT_FOR_DELIVERY';
|
||||||
|
v_status_code_normalized TEXT;
|
||||||
|
v_node_name TEXT := '广州天河网点';
|
||||||
|
v_location TEXT := '广州市 天河区';
|
||||||
|
|
||||||
|
v_source TEXT := 'webhook'; -- webhook/poll/manual
|
||||||
|
v_client_id TEXT := 'test_client';
|
||||||
|
v_signature_valid BOOLEAN := TRUE;
|
||||||
|
v_signature TEXT := 'fake-signature';
|
||||||
|
v_remote_ip INET := '127.0.0.1';
|
||||||
|
|
||||||
|
v_waybill_id UUID;
|
||||||
|
v_dedupe_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- 兼容旧值:不再使用 ARRIVED_HUB,统一归类为 IN_TRANSIT(运输中)
|
||||||
|
v_status_code_normalized := CASE
|
||||||
|
WHEN v_status_code = 'ARRIVED_HUB' THEN 'IN_TRANSIT'
|
||||||
|
WHEN v_status_code = 'PENDING' THEN 'ORDER_PLACED'
|
||||||
|
ELSE v_status_code
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 1) Upsert 运单主表(保证 waybill 存在)
|
||||||
|
INSERT INTO public.platform_express_waybills (
|
||||||
|
order_id,
|
||||||
|
order_no,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
source,
|
||||||
|
current_status_code,
|
||||||
|
current_status_text,
|
||||||
|
last_synced_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
v_order_no,
|
||||||
|
v_carrier,
|
||||||
|
v_tracking_no,
|
||||||
|
'mock',
|
||||||
|
v_status_code_normalized,
|
||||||
|
CASE
|
||||||
|
WHEN v_status_code = 'PENDING' THEN '已下单'
|
||||||
|
WHEN v_status_code = 'SHIPPED' THEN '已发货'
|
||||||
|
WHEN v_status_code = 'IN_TRANSIT' THEN '运输中'
|
||||||
|
WHEN v_status_code = 'ARRIVED_HUB' THEN '运输中'
|
||||||
|
WHEN v_status_code = 'OUT_FOR_DELIVERY' THEN '派送中'
|
||||||
|
WHEN v_status_code = 'DELIVERED' THEN '已签收'
|
||||||
|
WHEN v_status_code = 'EXCEPTION' THEN '异常'
|
||||||
|
ELSE v_status_code
|
||||||
|
END,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (carrier, tracking_no)
|
||||||
|
DO UPDATE SET
|
||||||
|
order_no = COALESCE(EXCLUDED.order_no, public.platform_express_waybills.order_no),
|
||||||
|
current_status_code = EXCLUDED.current_status_code,
|
||||||
|
current_status_text = EXCLUDED.current_status_text,
|
||||||
|
last_synced_at = EXCLUDED.last_synced_at
|
||||||
|
RETURNING id INTO v_waybill_id;
|
||||||
|
|
||||||
|
-- 2) 计算幂等键(优先 event_id;否则用运单+事件码+事件时间)
|
||||||
|
v_dedupe_key := COALESCE(
|
||||||
|
v_event_id,
|
||||||
|
v_tracking_no || '|' || v_event_code || '|' || to_char(v_event_time, 'YYYY-MM-DD"T"HH24:MI:SSOF')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3) 写入原始接收留痕(模拟第三方请求)
|
||||||
|
INSERT INTO public.platform_express_event_raw (
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
client_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
signature_valid,
|
||||||
|
signature,
|
||||||
|
ts_header,
|
||||||
|
request_id,
|
||||||
|
remote_ip,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
parse_error,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NOW(),
|
||||||
|
v_source,
|
||||||
|
v_client_id,
|
||||||
|
v_carrier,
|
||||||
|
v_tracking_no,
|
||||||
|
v_signature_valid,
|
||||||
|
v_signature,
|
||||||
|
EXTRACT(EPOCH FROM NOW())::text,
|
||||||
|
'req_fake_' || replace(v_dedupe_key, '|', '_'),
|
||||||
|
v_remote_ip,
|
||||||
|
'{"content-type":"application/json"}'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'order_no', v_order_no,
|
||||||
|
'carrier', v_carrier,
|
||||||
|
'tracking_no', v_tracking_no,
|
||||||
|
'event_id', v_event_id,
|
||||||
|
'event_time', v_event_time,
|
||||||
|
'event_code', v_event_code,
|
||||||
|
'event_text', v_event_text,
|
||||||
|
'status_code', v_status_code_normalized,
|
||||||
|
'node_name', v_node_name,
|
||||||
|
'location', v_location
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
'raw|' || v_dedupe_key
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4) 写入解析后的轨迹事件(用于时间线展示)
|
||||||
|
INSERT INTO public.platform_express_tracking_events (
|
||||||
|
waybill_id,
|
||||||
|
carrier,
|
||||||
|
tracking_no,
|
||||||
|
event_id,
|
||||||
|
event_time,
|
||||||
|
event_code,
|
||||||
|
event_text,
|
||||||
|
status_code,
|
||||||
|
node_name,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
evidence_urls,
|
||||||
|
raw_payload,
|
||||||
|
received_at,
|
||||||
|
source,
|
||||||
|
dedupe_key
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
v_waybill_id,
|
||||||
|
v_carrier,
|
||||||
|
v_tracking_no,
|
||||||
|
v_event_id,
|
||||||
|
v_event_time,
|
||||||
|
v_event_code,
|
||||||
|
v_event_text,
|
||||||
|
v_status_code_normalized,
|
||||||
|
v_node_name,
|
||||||
|
v_location,
|
||||||
|
NULL,
|
||||||
|
'[]'::jsonb,
|
||||||
|
NULL,
|
||||||
|
NOW(),
|
||||||
|
v_source,
|
||||||
|
v_dedupe_key
|
||||||
|
)
|
||||||
|
ON CONFLICT (waybill_id, dedupe_key) DO NOTHING;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- =====================================================================================
|
||||||
|
-- 清理示例(需要时手工执行)
|
||||||
|
-- =====================================================================================
|
||||||
|
-- BEGIN;
|
||||||
|
-- DELETE FROM public.platform_express_event_raw WHERE tracking_no LIKE 'TEST_%';
|
||||||
|
-- DELETE FROM public.platform_express_tracking_events WHERE tracking_no LIKE 'TEST_%';
|
||||||
|
-- DELETE FROM public.platform_express_waybills WHERE tracking_no LIKE 'TEST_%';
|
||||||
|
-- COMMIT;
|
||||||
124
pages/mall/delivery/doc/需求文档(现用)/uni-push2_安卓联调与取CID说明.md
Normal file
124
pages/mall/delivery/doc/需求文档(现用)/uni-push2_安卓联调与取CID说明.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# uni-push2 安卓联调与取 CID 说明
|
||||||
|
|
||||||
|
## 1. 目的与范围
|
||||||
|
|
||||||
|
本文档用于记录本项目在 **uni-app x(UVUE + UTS)** 下,对 DCloud **统一推送 uni-push2** 的安卓端最小化接入与验证流程,目标是完成闭环:
|
||||||
|
|
||||||
|
1) 真机运行应用
|
||||||
|
2) 获取 CID(clientId)
|
||||||
|
3) 在 uni-push2 控制台使用“指定 CID”推送
|
||||||
|
4) 设备收到推送(通知栏/透传回调)
|
||||||
|
|
||||||
|
> 说明:本文聚焦“能跑通验证链路”的最小方案;厂商通道(小米/华为等)的离线推送配置属于后续增强。
|
||||||
|
|
||||||
|
## 2. 关键结论(先看这个)
|
||||||
|
|
||||||
|
- **调试基座 ≠ 你的应用**:在 HBuilderX 使用“运行到 Android 设备(标准基座)”拿到的 CID,归属是“基座应用”,因此在你的 uni-push2 应用里按 CID 推送会提示“CID 不存在或不属于此应用”。
|
||||||
|
- 要让 CID 归属正确,必须使用 **云打包/本地打包** 生成的 **你自己的 APK**,安装到手机后再获取 CID。
|
||||||
|
- 不依赖控制台日志获取 CID:为了避免无法抓取 logcat 的情况,已实现 **把 CID 写入本地存储**,并在 minimal 页面 **直接展示 + 点击复制**。
|
||||||
|
|
||||||
|
## 3. 环境说明
|
||||||
|
|
||||||
|
- 框架:uni-app x(UVUE + UTS)
|
||||||
|
- 平台:Android 真机(红米/小米系统 MIUI/HyperOS 等)
|
||||||
|
- 推送:DCloud uni-push2
|
||||||
|
|
||||||
|
## 4. 客户端实现(最小可用)
|
||||||
|
|
||||||
|
### 4.1 App 启动初始化与监听
|
||||||
|
|
||||||
|
在应用启动时:
|
||||||
|
|
||||||
|
- 调用 `uni.onPushMessage` 监听推送回调(用于验证是否收到推送/透传)
|
||||||
|
- 调用 `uni.getPushClientId` 获取 CID
|
||||||
|
- 将 CID 输出日志,同时写入本地存储 `uni_push2_cid`
|
||||||
|
|
||||||
|
涉及文件:
|
||||||
|
- `App.uvue`
|
||||||
|
|
||||||
|
注意事项(UTS 语法/类型限制):
|
||||||
|
- UTS 对 `Any`/`Any?` 较严格,不能随意使用 TS 写法(如 `(x as any)`)
|
||||||
|
- 对 `ret.cid` 这类对 `Any` 的直接属性访问可能编译失败,需要用 `UTSJSONObject` 或字符串兜底
|
||||||
|
|
||||||
|
### 4.2 minimal 页面展示 CID 并一键复制
|
||||||
|
|
||||||
|
为了不依赖 HBuilderX 控制台/adb:
|
||||||
|
|
||||||
|
- 从本地存储读取 `uni_push2_cid`
|
||||||
|
- 页面展示 CID
|
||||||
|
- 点击 CID 触发 `uni.setClipboardData` 复制
|
||||||
|
|
||||||
|
涉及文件:
|
||||||
|
- `pages/minimal.uvue`
|
||||||
|
|
||||||
|
## 5. 打包与安装(验证闭环必需)
|
||||||
|
|
||||||
|
### 5.1 云打包
|
||||||
|
|
||||||
|
- 在 HBuilderX 执行 Android 云端打包
|
||||||
|
- 打包成功后会得到下载链接
|
||||||
|
|
||||||
|
下载次数限制(常见问题):
|
||||||
|
- 云端下载链接可能存在短时间“下载次数超限”
|
||||||
|
- 规避策略:
|
||||||
|
- 尽量 **只下载一次并备份**(网盘/U盘/微信文件传输助手)
|
||||||
|
- 必要时更换网络/IP(例如手机热点)
|
||||||
|
- 仍受限时,重新云打包生成新下载链接
|
||||||
|
|
||||||
|
### 5.2 手机安装与启动
|
||||||
|
|
||||||
|
- 建议卸载旧的调试基座或旧包,避免混淆
|
||||||
|
- 通过系统安装 APK
|
||||||
|
- 桌面无图标时(MIUI/HyperOS 常见):
|
||||||
|
- 在系统“应用列表/管理应用”中搜索应用名进入详情页打开
|
||||||
|
- 或在应用抽屉里搜索应用名,长按拖到桌面
|
||||||
|
|
||||||
|
## 6. 获取 CID 的两种方式
|
||||||
|
|
||||||
|
### 6.1 推荐:从 minimal 页面直接复制(无需日志)
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1) 安装云打包 APK
|
||||||
|
2) 打开应用进入 Minimal Test Page
|
||||||
|
3) 等待 10–30 秒(必要时重启一次 App)
|
||||||
|
4) 页面显示 CID,点击即可复制
|
||||||
|
|
||||||
|
### 6.2 备选:adb logcat 抓日志(需要电脑装 adb)
|
||||||
|
|
||||||
|
- 电脑安装 Android SDK Platform-Tools
|
||||||
|
- 开启手机 USB 调试并授权
|
||||||
|
- 使用 `adb logcat` 过滤关键词(如:`uni-push2`、`clientId`、`cid`)
|
||||||
|
|
||||||
|
> 说明:不建议通过 HBuilderX “运行到 Android 设备(标准基座)”来取日志,因为会回到基座环境,CID 归属不正确。
|
||||||
|
|
||||||
|
## 7. 控制台推送验证步骤
|
||||||
|
|
||||||
|
1) 确认使用的 uni-push2 控制台账号/应用,与打包 APK 对应的 AppID 一致
|
||||||
|
2) 在控制台选择“消息推送/测试推送 → 指定 CID”
|
||||||
|
3) 粘贴从 minimal 页面复制的 CID
|
||||||
|
4) 发送通知消息
|
||||||
|
|
||||||
|
如果提示“CID 不存在或不属于此应用”,按以下顺序排查:
|
||||||
|
- 是否仍在使用调试基座的 CID(需要用云打包 APK 重新取 CID)
|
||||||
|
- 是否账号/应用选错(AppID 不一致)
|
||||||
|
- 是否刚安装/首次启动(CID 同步可能有延迟,等 1–3 分钟再试)
|
||||||
|
|
||||||
|
## 8. 厂商通道(小米等)说明(可选)
|
||||||
|
|
||||||
|
- 厂商通道用于离线推送/更稳定通知
|
||||||
|
- 若云打包勾选/启用小米推送模块但未配置参数,可能触发校验提示
|
||||||
|
- 最小验证路径:先确保能完成“在线推送/控制台指定 CID”闭环,再按需要补齐厂商通道
|
||||||
|
|
||||||
|
## 9. 本次相关改动点(便于回溯)
|
||||||
|
|
||||||
|
- `App.uvue`
|
||||||
|
- 初始化 `uni.onPushMessage`
|
||||||
|
- 获取 CID 并写入 `uni.setStorageSync('uni_push2_cid', cid)`
|
||||||
|
- `pages/minimal.uvue`
|
||||||
|
- 读取 `uni.getStorageSync('uni_push2_cid')`
|
||||||
|
- 展示 CID,点击复制
|
||||||
|
- 修复 UTS 的 `Any?` 类型推断问题(避免强制标注 `any`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如需把“推送触达率/离线推送(小米/华为/OPPO/vivo)/通知样式与点击跳转”纳入需求范围,请在本文基础上补充对应厂商后台开通、证书参数与多通道策略。
|
||||||
75
pages/mall/delivery/doc/需求文档(现用)/前端字段清单.md
Normal file
75
pages/mall/delivery/doc/需求文档(现用)/前端字段清单.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 前端字段清单(订单详情页物流时间线|安卓 & Web)
|
||||||
|
|
||||||
|
说明:本清单用于平台前端展示“物流关键节点时间线”。数据来源为平台后端消费 Mock Server(或未来真实承运方)的事件后形成的统一模型。
|
||||||
|
|
||||||
|
本文定位:只定义“平台后端 -> 客户端”的展示字段契约与规则;Mock Server 的接口与字段以 `接口规范.md` 为准。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `配送模块需求文档.md`:目标/范围/验收与故障注入
|
||||||
|
- `接口规范.md`:统一事件模型、平台接收 Webhook、Mock 控制面 API
|
||||||
|
- `状态映射表.md`:event_code -> status_code 映射建议
|
||||||
|
|
||||||
|
适用端:
|
||||||
|
- 安卓端(App):重点是用户体验(弱网/图片预览/快速刷新)。
|
||||||
|
- Web 端(H5/PC):重点是排障效率(筛选/搜索/原始事件折叠展示)。
|
||||||
|
|
||||||
|
1. 订单详情(delivery block)
|
||||||
|
- `order_no`:订单号
|
||||||
|
- `tracking_no` / `waybill_no`:运单号(可能为空)
|
||||||
|
- `carrier`:承运商标识(YUNDA/YTO/KDN 等)
|
||||||
|
- `status`:平台统一状态码(ORDER_PLACED/SHIPPED/IN_TRANSIT/OUT_FOR_DELIVERY/READY_FOR_PICKUP/DELIVERED/EXCEPTION/RETURNED)
|
||||||
|
- `status_history`:事件数组,元素结构 `{event_id, event_time, event_code, event_text, status_code, node_name, location, description, evidence_urls, source}`
|
||||||
|
- `eta`:预计到达时间(ISO8601,若承运方或 ETA 服务提供)
|
||||||
|
- `pod`:签收凭证数组,元素 `{type: photo|signature, url, timestamp, remark}`
|
||||||
|
- `receiver_name`:收件人姓名(仅客服可查看完整)
|
||||||
|
- `receiver_masked_phone`:脱敏手机号(前端展示)
|
||||||
|
- `receiver_address`:收货地址(可按需隐藏部分字段)
|
||||||
|
- `last_synced_at`:最后同步时间(平台本地)
|
||||||
|
- `source`:来源(mock / carrier / aggregator),用于 UI 标注与排障
|
||||||
|
|
||||||
|
2. 管理/调试视图字段(可选)
|
||||||
|
- `mock_enabled`:是否开启 mock 数据
|
||||||
|
- `mock_carrier`:当前模拟承运方
|
||||||
|
- `webhook_last_result`:最近一次 webhook 推送结果摘要(仅调试页面)
|
||||||
|
|
||||||
|
3. 行为与按钮映射(平台侧)
|
||||||
|
- 刷新物流 -> GET /api/v1/delivery/express/track(平台查询自身已入库事件;或平台发起轮询)
|
||||||
|
- 上报异常 -> POST /api/v1/delivery/express/report-exception
|
||||||
|
|
||||||
|
(联调阶段可选)平台侧调试按钮(调用 Mock Server 控制面 API):
|
||||||
|
- 运行标准场景 -> POST /mock/v1/waybills/{tracking_no}/run-scenario
|
||||||
|
- 触发推送 -> POST /mock/v1/waybills/{tracking_no}/push
|
||||||
|
|
||||||
|
4. 展示规则 & 隐私
|
||||||
|
- 所有时间使用 ISO8601(含时区)。
|
||||||
|
- 手机号必须脱敏(如 138****8000),完整手机号仅在客服/运维界面显示并记录访问审计。
|
||||||
|
- 证据 URL 应带鉴权或短期有效,前端需支持预览与下载。
|
||||||
|
|
||||||
|
4.1 同源数据,不同端展示颗粒度
|
||||||
|
- 同一运单的轨迹事件(`event_code/event_text/status_code/node_name/location`)必须同源,保证事实与状态口径一致;不同端仅做“展示层过滤/脱敏”,不做口径改写。
|
||||||
|
- 商家端(B 端):只需展示大致进度(到达某地/中转/派送/签收/异常)与最新节点摘要;不展示配送员/快递员手机号等个人敏感信息。
|
||||||
|
- 消费者端(C 端):可展示更详细的节点文案;若提供“联系配送”能力,建议用按钮/虚拟号/中转号承载,而不是把第三方 `event_text` 中的手机号原文直接展示或通过消息推送透传。
|
||||||
|
|
||||||
|
4.2 `event_text` 使用建议(避免直接透传敏感信息)
|
||||||
|
- `event_text` 属于第三方原文,可能包含手机号、网点内部信息等;建议前端展示使用“清洗后的文案”。
|
||||||
|
- 最小实现:
|
||||||
|
- 商家端优先展示 `status_code + node_name/location` 组合出的摘要;必要时展示清洗后的 `event_text`。
|
||||||
|
- 消费者端可展示清洗后的 `event_text`;仅在 `OUT_FOR_DELIVERY` 等末端节点按需提供脱敏联系方式。
|
||||||
|
- 口径要求:除末端状态(派送中/待取件/已签收/异常/退回等)外,中转/到达/发往下一站等统一展示为“运输中”,不展示“到达××网点/分拨中心”等中间节点状态文案。
|
||||||
|
|
||||||
|
5. 安卓端适配要点
|
||||||
|
- 缓存:将最近一次 `status_history` 缓存在本地(仅该用户的订单范围),离线可展示并提示“数据可能不是最新”。
|
||||||
|
- 图片:POD/证据图片支持大图预览与失败重试;弱网下优先加载文本节点。
|
||||||
|
- 刷新:进入页面自动拉取;下拉刷新;显示 `last_synced_at`。
|
||||||
|
|
||||||
|
6. Web 端适配要点
|
||||||
|
- 调试信息:`raw_payload` 默认折叠,仅客服/运维可展开查看。
|
||||||
|
- 筛选搜索:按 `status_code`、时间范围、承运方 `carrier` 过滤;支持按 `tracking_no` 搜索。
|
||||||
|
- 安全:避免在前端日志中打印 `raw_payload` 与敏感字段。
|
||||||
|
|
||||||
|
7. 兼容性
|
||||||
|
- 前端组件可接收 `raw_payload`(仅供客服/运维查看)并折叠显示。
|
||||||
|
- 时间线组件需支持展开单条事件详情、证据大图预览与来源标注(承运方/聚合方)。
|
||||||
|
|
||||||
|
---
|
||||||
|
(以上为前端字段与契约建议,实际字段名可与后端协商最终确认)
|
||||||
194
pages/mall/delivery/doc/需求文档(现用)/后台页面设计说明.md
Normal file
194
pages/mall/delivery/doc/需求文档(现用)/后台页面设计说明.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# 后台页面设计说明(配送模块|平台对接第三方轨迹)
|
||||||
|
|
||||||
|
本文定位:
|
||||||
|
- 定义“后台应该有什么样的页面”与每个页面的最小展示/操作/权限要求。
|
||||||
|
- 本文服务于平台系统的一部分:订单详情页物流区块 + 客服/履约/对接运维排障。
|
||||||
|
|
||||||
|
口径说明:本目录文档作为实现与验收唯一口径;如页面形态与本文不一致,以本文更新为准。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `配送模块需求文档.md`:范围/目标/验收与总体定位
|
||||||
|
- `接口规范.md`:Webhook/控制面接口与入库建议
|
||||||
|
- `前端字段清单.md`:订单详情页物流区块字段契约(安卓/Web)
|
||||||
|
- `状态映射表.md`:event_code/event_text -> status_code 映射与一致性治理
|
||||||
|
- `缺口与待补充清单.md`:需补齐的验收/场景/权限矩阵等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 角色与权限(页面设计前提)
|
||||||
|
|
||||||
|
角色建议:
|
||||||
|
- 普通用户(消费者端):仅查看自己订单物流(不在本后台范围内)
|
||||||
|
- 商家(商户后台):发货与查看订单物流(不看 raw_payload)
|
||||||
|
- 客服:查看订单物流、辅助处理异常(有限查看 raw_payload,需审计)
|
||||||
|
- 履约/运营:看指标、看异常趋势、做配置(按权限)
|
||||||
|
- 对接运维:看 webhook 日志、验签结果、配置密钥与回调、排障(可看 raw_payload,需审计)
|
||||||
|
|
||||||
|
强约束:
|
||||||
|
- `raw_payload`、完整手机号/地址、签收凭证(POD)属于敏感信息:默认折叠、按角色授权、访问需审计。
|
||||||
|
|
||||||
|
### 1.1 页面拆分原则 & 权限控制原则(避免跑偏)
|
||||||
|
|
||||||
|
结论:页面是否拆分不只由“权限”决定;更主要由“工作流/信息密度/风险”决定,权限用于控制同一页面内的可见字段与可操作按钮。
|
||||||
|
|
||||||
|
页面拆分建议(什么时候需要单独页面/专区):
|
||||||
|
- 工作流不同:商家做发货/运单绑定;客服做工单/异常处理;运维做对接配置/排障。
|
||||||
|
- 信息密度不同:面向商家的页面应短而清晰;面向运维的页面需要更细的日志与诊断信息。
|
||||||
|
- 风险不同:密钥/证书、回调配置、`raw_payload` 查看等高敏能力建议放在“对接运维专区”或独立页面,降低误操作与暴露面。
|
||||||
|
|
||||||
|
权限控制建议(同一页面内怎么按角色展示):
|
||||||
|
- 字段级:例如 `raw_payload`、完整手机号/地址、POD 仅授权角色可见;默认折叠。
|
||||||
|
- 操作级:例如“触发回查/补采”“修改接入配置/密钥”“启用 Mock 开关”等仅运维可操作。
|
||||||
|
- 审计级:对敏感字段访问与关键配置变更必须记录审计。
|
||||||
|
|
||||||
|
推荐做法:
|
||||||
|
- 信息架构先按人群拆两条导航线(商家后台 vs 平台后台)。
|
||||||
|
- 在每个页面内部再做字段/按钮的权限控制(同页不同角色看到的内容不同)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 信息架构(IA)与导航建议
|
||||||
|
|
||||||
|
建议将后台拆成两条导航线:
|
||||||
|
|
||||||
|
A. 商家后台(商户侧)
|
||||||
|
- 订单
|
||||||
|
- 订单列表
|
||||||
|
- 订单详情(含物流区块 + 发货/运单绑定)
|
||||||
|
- 设置(可选)
|
||||||
|
- 默认承运方/发货偏好
|
||||||
|
|
||||||
|
B. 平台后台(客服/运维侧)
|
||||||
|
- 订单
|
||||||
|
- 订单查询
|
||||||
|
- 订单详情(含物流区块)
|
||||||
|
- 物流对接
|
||||||
|
- 运单/轨迹查询(排障)
|
||||||
|
- Webhook 接收日志
|
||||||
|
- 接入配置中心(承运方/聚合配置)
|
||||||
|
- 监控与告警
|
||||||
|
- 状态映射治理(可选增强)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 商家后台页面(最小集)
|
||||||
|
|
||||||
|
### 3.1 订单列表页(商家)
|
||||||
|
目的:商家快速定位需要发货/查看物流的订单。
|
||||||
|
|
||||||
|
最小展示:
|
||||||
|
- `order_no`、下单时间、收件人(脱敏)、订单状态、发货状态
|
||||||
|
- 物流摘要(若已发货):`carrier`、`tracking_no`、当前 `status_code`
|
||||||
|
|
||||||
|
最小操作:
|
||||||
|
- 查看订单详情
|
||||||
|
- 发货(跳转/弹窗进入“承运方选择 + 运单号绑定”)
|
||||||
|
|
||||||
|
### 3.2 订单详情页(商家,含物流区块)
|
||||||
|
目的:完成发货动作,并查看物流时间线。
|
||||||
|
|
||||||
|
物流区块展示(以 `前端字段清单.md` 为准):
|
||||||
|
- `carrier`、`tracking_no`、`status`、`status_history`、`last_synced_at`
|
||||||
|
|
||||||
|
操作:
|
||||||
|
- 发货:选择承运方 + 录入/回填 `tracking_no`
|
||||||
|
- 刷新物流:调用平台统一查询(不直连第三方)
|
||||||
|
|
||||||
|
禁止/限制:
|
||||||
|
- 不展示 `raw_payload`
|
||||||
|
- 收件信息按平台规则脱敏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 平台后台页面(客服/履约/对接运维)
|
||||||
|
|
||||||
|
### 4.1 订单查询页(平台)
|
||||||
|
目的:客服/履约按订单维度查找并进入详情处理。
|
||||||
|
|
||||||
|
筛选:
|
||||||
|
- `order_no`、`tracking_no`、`carrier`、`status_code`、时间范围
|
||||||
|
|
||||||
|
展示:
|
||||||
|
- 订单摘要 + 物流摘要(当前状态、最后同步时间)
|
||||||
|
|
||||||
|
### 4.2 订单详情页(平台,含物流区块加强版)
|
||||||
|
目的:在订单上下文内查看完整物流与异常。
|
||||||
|
|
||||||
|
展示:
|
||||||
|
- 物流区块:同商家侧字段 + 事件来源标注(mock/承运方/聚合)
|
||||||
|
- 异常标记:长时间无更新、签收失败、退回等(按规则)
|
||||||
|
- POD(可选):签收凭证预览(按权限)
|
||||||
|
|
||||||
|
操作:
|
||||||
|
- 刷新/回查(可选增强):触发平台轮询补偿或第三方回查
|
||||||
|
- 备注/工单关联(可选):记录客服处理信息
|
||||||
|
|
||||||
|
### 4.3 运单/轨迹查询页(平台排障核心)
|
||||||
|
目的:脱离订单也能按运单快速排障。
|
||||||
|
|
||||||
|
筛选:
|
||||||
|
- `tracking_no`、`carrier`、`status_code`、时间范围、来源(mock/承运方/聚合)
|
||||||
|
|
||||||
|
展示:
|
||||||
|
- 事件时间线(入库后统一模型)
|
||||||
|
- 幂等/乱序结果提示(例如:去重命中次数、最新事件时间)
|
||||||
|
|
||||||
|
操作:
|
||||||
|
- 复制对接诊断信息(不含敏感字段)
|
||||||
|
|
||||||
|
### 4.4 Webhook 接收日志页(平台对接运维)
|
||||||
|
目的:定位“回调未到/验签失败/重复推送/入库失败”。
|
||||||
|
|
||||||
|
筛选:
|
||||||
|
- 时间范围、`carrier`、`tracking_no`、验签结果、HTTP 状态、处理结果
|
||||||
|
|
||||||
|
展示:
|
||||||
|
- 请求摘要:`X-Client-Id`、`X-Timestamp`、签名校验结果
|
||||||
|
- 处理摘要:解析成功/失败原因、幂等键、入库结果
|
||||||
|
- 原始回文:`raw_payload` 折叠展示(仅授权角色可见,且记录访问审计)
|
||||||
|
|
||||||
|
### 4.5 接入配置中心(平台对接运维)
|
||||||
|
目的:管理承运方/聚合接入与回调配置。
|
||||||
|
|
||||||
|
配置项:
|
||||||
|
- 承运方列表与状态(启用/停用)
|
||||||
|
- 验签密钥/证书、公网 IP 白名单(如需要)
|
||||||
|
- Webhook 目标地址与环境(测试/预发/生产)
|
||||||
|
- 轮询补偿策略:频率、阈值(多久无更新触发)、熔断
|
||||||
|
- 测试环境 Mock 开关(默认关闭,需显式开启)
|
||||||
|
|
||||||
|
### 4.6 监控与告警页(平台履约/运维)
|
||||||
|
目的:按承运方/接口维度观察质量。
|
||||||
|
|
||||||
|
指标建议:
|
||||||
|
- webhook 接收成功率、验签失败率、平均同步延迟
|
||||||
|
- 超过阈值未更新的运单数
|
||||||
|
- 第三方接口错误率/超时率(轮询补偿场景)
|
||||||
|
|
||||||
|
### 4.7 状态映射治理页(可选增强)
|
||||||
|
目的:让 `event_code -> status_code` 可治理、可追溯。
|
||||||
|
|
||||||
|
展示:
|
||||||
|
- 当前映射表、变更记录、(可选)映射版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MVP(最小可上线范围)
|
||||||
|
|
||||||
|
商家后台:
|
||||||
|
- 订单列表 + 订单详情(含发货与物流区块)
|
||||||
|
|
||||||
|
平台后台:
|
||||||
|
- 运单/轨迹查询
|
||||||
|
- Webhook 接收日志(含验签/去重/入库结果)
|
||||||
|
- 接入配置中心(至少支持配置承运方/密钥/回调目标)
|
||||||
|
- 监控与告警(至少有基础指标与列表)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验收要点(页面层)
|
||||||
|
|
||||||
|
- 订单详情页物流区块字段与展示规则符合 `前端字段清单.md`
|
||||||
|
- 平台后台能从运单号定位到:事件时间线 + webhook 接收记录 + 验签结果 + 入库结果
|
||||||
|
- `raw_payload` 默认不可见/折叠,且按权限展示并有审计
|
||||||
|
- Mock 开关默认关闭,且仅在测试环境允许启用
|
||||||
516
pages/mall/delivery/doc/需求文档(现用)/接口规范.md
Normal file
516
pages/mall/delivery/doc/需求文档(现用)/接口规范.md
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
# 配送对接接口规范(模拟三通一达后台 / Mock 承运方 Server)
|
||||||
|
|
||||||
|
本文档用于定义“模拟承运方后台服务(Mock Server)”的接口与行为,供平台在未接入真实承运方前完成联调与测试。
|
||||||
|
|
||||||
|
本文定位:
|
||||||
|
- 规定 Mock Server 与平台后端之间的交互(Webhook 推送 + 控制面 API)。
|
||||||
|
- 规定平台侧建议的统一事件模型(用于入库、时间线展示、监控告警)。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `配送模块需求文档.md`:目标/范围/验收与必须的故障注入
|
||||||
|
- `状态映射表.md`:状态码映射建议(Mock event_code -> 平台 status_code)
|
||||||
|
- `前端字段清单.md`:安卓/Web 时间线展示字段契约
|
||||||
|
|
||||||
|
包含两类接口:
|
||||||
|
1) Mock Server -> 平台:Webhook 事件推送(模拟承运方回调)
|
||||||
|
2) 平台/测试工具 -> Mock Server:控制面 API(创建运单、追加事件、运行场景、触发推送、查询轨迹)
|
||||||
|
|
||||||
|
## 统一约定
|
||||||
|
- 时间格式:ISO 8601(示例:"2026-02-05T10:30:00+08:00")
|
||||||
|
- 所有接口请求/响应均为 JSON,Content-Type: application/json
|
||||||
|
- 写操作建议携带 `request_id` 用于幂等与排查
|
||||||
|
- 所有第三方回调应保存 `raw_payload` 用于审计
|
||||||
|
|
||||||
|
补充说明(安卓端 & Web 端使用场景):
|
||||||
|
- 本文主要规范 Mock Server 与平台后端的交互;安卓/Web 客户端不应直接调用 Mock Server。
|
||||||
|
- Web 端联调时如需浏览器直连平台接口,应由平台网关配置 CORS(仅测试环境白名单),避免将 Mock Server 暴露给公网。
|
||||||
|
|
||||||
|
## 一、目标与约束
|
||||||
|
- 目标:用可控数据模拟关键配送节点与异常场景,覆盖验签、幂等、乱序/重复、延迟等测试。
|
||||||
|
- 约束:本规范服务于测试/预发布环境,不用于生产承运方直连。
|
||||||
|
|
||||||
|
## 二、核心数据模型(建议统一存储字段)
|
||||||
|
- `carrier`:承运方标识(例如:YUNDA、YTO、KDN)
|
||||||
|
- `tracking_no`:运单号
|
||||||
|
- `event_id`:第三方事件唯一ID(用于去重)
|
||||||
|
- `event_time`:事件时间(ISO8601)
|
||||||
|
- `event_code`:第三方原始事件码(尽量不改写,用于审计与一致性对齐)
|
||||||
|
- `event_text`:第三方原始事件文案(前端时间线默认展示)
|
||||||
|
- `status_code`:平台统一状态(ORDER_PLACED, SHIPPED, IN_TRANSIT, OUT_FOR_DELIVERY, READY_FOR_PICKUP, DELIVERED, EXCEPTION, RETURNED)
|
||||||
|
- `node_name`:节点名称(中转站/网点/城市)
|
||||||
|
- `location`:节点位置描述(城市/网点地址,注意隐私)
|
||||||
|
- `description`:事件详细描述
|
||||||
|
- `evidence_urls`:证据照片/签名链接数组
|
||||||
|
- `estimated_arrival`:ETA(若承运方提供,可选)
|
||||||
|
- `raw_payload`:原始第三方 JSON(审计)
|
||||||
|
- `last_synced_at`:本地同步时间
|
||||||
|
|
||||||
|
## 三、接入模式
|
||||||
|
- 平台接收 Mock Server 的 Webhook 推送(推荐)。
|
||||||
|
- 平台亦可主动调用 Mock Server 查询轨迹(模拟轮询补偿)。
|
||||||
|
|
||||||
|
范围说明(避免误解):
|
||||||
|
- 若要实现“电子面单/在线下单自动获取运单号”,需要对接第三方(快递公司直连或聚合平台)提供的下单/面单接口。
|
||||||
|
- 该类“下单/面单”接口不属于本 Mock Server 接口规范范围;本文只覆盖轨迹事件的推送/查询与相关验签、幂等与字段口径。
|
||||||
|
|
||||||
|
## 四、接口定义(示例)
|
||||||
|
|
||||||
|
1) Mock Server -> 平台:事件推送(Webhook)
|
||||||
|
- URL: POST /webhook/express/status
|
||||||
|
- 描述: 承运方/聚合方主动推送运单事件(单条或批量事件)。平台应校验签名并返回 HTTP 200 表示成功接收。
|
||||||
|
- HTTP 头部(建议):
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `X-Client-Id`: 承运方或聚合方ID
|
||||||
|
- `X-Timestamp`: 推送时间戳(防重放)
|
||||||
|
- `X-Signature`: HMAC-SHA256(body + X-Timestamp) 使用双方共享 secret
|
||||||
|
- 回调示例(单事件):
|
||||||
|
{
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"carrier":"YUNDA",
|
||||||
|
"event_id":"e_20260205_0001",
|
||||||
|
"event_code":"TRANSIT",
|
||||||
|
"event_text":"运输中",
|
||||||
|
"event_time":"2026-02-05T14:32:00+08:00",
|
||||||
|
"node_name":"北京分拨中心",
|
||||||
|
"location":"北京市朝阳区XXX",
|
||||||
|
"evidence_urls":[],
|
||||||
|
"raw_payload":{/* 原始承运方 JSON */}
|
||||||
|
}
|
||||||
|
|
||||||
|
处理要求:
|
||||||
|
- 平台校验 `X-Signature` 和 `X-Timestamp`;若验签失败或时间差过大返回 4xx。若成功返回 HTTP 200。接收后异步完成映射与入库。
|
||||||
|
- 幂等:基于 `event_id` 或 `tracking_no+event_code+event_time` 去重。
|
||||||
|
|
||||||
|
2) 平台 -> Mock Server:轨迹主动拉取(模拟轮询)
|
||||||
|
- URL: GET /mock/v1/track?tracking_no={tracking_no}&carrier={carrier}
|
||||||
|
- 描述: 平台主动查询单运单轨迹,返回事件数组。
|
||||||
|
- 响应示例:
|
||||||
|
{
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"carrier":"YUNDA",
|
||||||
|
"events":[
|
||||||
|
{"event_id":"e1","event_code":"PICKED","event_text":"已揽收","event_time":"2026-02-04T18:00:00+08:00","node_name":"门店揽收网点","location":"北京市顺义"},
|
||||||
|
{"event_id":"e2","event_code":"ARRIVAL","event_text":"运输中","event_time":"2026-02-05T14:32:00+08:00","node_name":"北京分拨中心","location":"北京市朝阳区"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
3) 平台:异常上报 API(用户/客服触发,平台侧,不属于 Mock Server)
|
||||||
|
- URL: POST /api/v1/delivery/express/report-exception
|
||||||
|
- 说明: 该接口为平台自身能力,Mock Server 不强制实现。
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"reported_by":"user",
|
||||||
|
"report_type":"damaged",
|
||||||
|
"description":"包裹外包装破损,有压痕",
|
||||||
|
"evidence_urls":["https://.../img1.jpg"]
|
||||||
|
}
|
||||||
|
- 平台可选地回传给真实承运方(若未来接入)并记录回传结果。
|
||||||
|
|
||||||
|
4) 平台/测试工具 -> Mock Server:控制面 API(建议实现)
|
||||||
|
|
||||||
|
4.1 配置回调目标
|
||||||
|
- URL: POST /mock/v1/config
|
||||||
|
- 描述: 设置 Mock Server 推送到平台的 Webhook 目标地址与验签参数。
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"target_webhook_url":"https://api.yourplatform.com/webhook/express/status",
|
||||||
|
"client_id":"carrier_mock_yunda",
|
||||||
|
"secret":"shared_secret",
|
||||||
|
"default_carrier":"YUNDA"
|
||||||
|
}
|
||||||
|
|
||||||
|
4.2 创建运单
|
||||||
|
- URL: POST /mock/v1/waybills
|
||||||
|
- 描述: 创建一个 mock 运单,可由调用方指定或由服务生成。
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"carrier":"YUNDA",
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"order_no":"202602050001"
|
||||||
|
}
|
||||||
|
- 响应示例:
|
||||||
|
{
|
||||||
|
"carrier":"YUNDA",
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"created_at":"2026-02-05T14:00:00+08:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
4.3 追加事件(不推送,仅入库)
|
||||||
|
- URL: POST /mock/v1/waybills/{tracking_no}/events
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"event_id":"e_20260205_0002",
|
||||||
|
"event_code":"TRANSIT",
|
||||||
|
"event_text":"运输中",
|
||||||
|
"event_time":"2026-02-05T14:32:00+08:00",
|
||||||
|
"node_name":"北京分拨中心",
|
||||||
|
"location":"北京市朝阳区",
|
||||||
|
"evidence_urls":[]
|
||||||
|
}
|
||||||
|
|
||||||
|
4.4 运行预置场景(生成一组事件并可选立即推送)
|
||||||
|
- URL: POST /mock/v1/waybills/{tracking_no}/run-scenario
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"scenario":"standard_delivered",
|
||||||
|
"push":true,
|
||||||
|
"inject":{
|
||||||
|
"delay_ms":300,
|
||||||
|
"duplicate":0,
|
||||||
|
"out_of_order":false,
|
||||||
|
"bad_signature":false,
|
||||||
|
"timestamp_skew_seconds":0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
4.5 触发推送(把该运单事件推到平台)
|
||||||
|
- URL: POST /mock/v1/waybills/{tracking_no}/push
|
||||||
|
- 请求示例:
|
||||||
|
{
|
||||||
|
"mode":"all",
|
||||||
|
"inject":{
|
||||||
|
"delay_ms":0,
|
||||||
|
"duplicate":1,
|
||||||
|
"out_of_order":true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
4.6 查询运单轨迹(Mock Server 自身查询接口)
|
||||||
|
- URL: GET /mock/v1/waybills/{tracking_no}/track
|
||||||
|
- 响应示例:
|
||||||
|
{
|
||||||
|
"carrier":"YUNDA",
|
||||||
|
"tracking_no":"YD123456789",
|
||||||
|
"events":[
|
||||||
|
{"event_id":"e1","event_code":"PICKED","event_text":"已揽收","event_time":"2026-02-04T18:00:00+08:00"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
4.7 健康检查
|
||||||
|
- URL: GET /mock/v1/health
|
||||||
|
- 响应示例:
|
||||||
|
{"status":"ok"}
|
||||||
|
|
||||||
|
## 五、字段映射与状态对照(示例)
|
||||||
|
- 平台统一状态 `OUT_FOR_DELIVERY` 映射关系示例:
|
||||||
|
- 韵达:承运方 `派送中` -> 平台 `OUT_FOR_DELIVERY`
|
||||||
|
- 聚合:原始 `deliver` -> 平台 `OUT_FOR_DELIVERY`
|
||||||
|
|
||||||
|
状态一致性策略(尽量保持平台与第三方一致):
|
||||||
|
- 一致性定义:平台保存的 `event_code/event_text` 与 `raw_payload` 应与第三方可查询到的轨迹语义一致;平台对外展示的 `status_code` 必须由映射表确定性生成(同一承运方同一 `event_code` 在同一版本映射下得到同一 `status_code`)。
|
||||||
|
- 字段保真:`event_code/event_text` 仅做透传与脱敏展示,不建议“为了统一文案”而改写原文;统一展示口径使用 `status_code` 的标签/颜色/筛选实现。
|
||||||
|
- 映射表治理:映射表变更需要版本化(例如 `mapping_version`)并走发布流程;避免在生产环境频繁调整导致历史轨迹“同码不同态”。如必须调整,建议补充回放/回填策略以保证历史一致。
|
||||||
|
- 回查纠偏(可选增强):平台定期或按需对“关键单/争议单”触发第三方轨迹回查;若发现缺失事件则补采入库;若发现语义冲突,优先保留原始事件并追加“平台侧纠偏事件/备注”,避免静默覆盖造成审计对不上。
|
||||||
|
|
||||||
|
## 六、验签与安全
|
||||||
|
- 建议使用 HMAC-SHA256 签名,签名字段为 `body + X-Timestamp`,服务端使用共享 `secret` 校验。
|
||||||
|
- 防重放:校验 `X-Timestamp` 与当前时间差(例如 < 5 分钟),并保存最近 N 个 `X-Signature` 或 `event_id` 用于去重。
|
||||||
|
|
||||||
|
## 七、幂等与重试策略
|
||||||
|
- webhook:承运方可能重试多次,平台必须基于 `event_id` 或 (`tracking_no`+`event_code`+`event_time`) 去重。
|
||||||
|
- 主动调用承运方接口失败时采用指数退避重试;关键操作(打单)建议入队异步重试并人工告警。
|
||||||
|
|
||||||
|
入库层(幂等去重、乱序处理)建议:
|
||||||
|
- 幂等目标:同一条事件无论推送/重试多少次,最终数据库只保留一条(或同一条被安全更新),避免重复入库造成时间线膨胀。
|
||||||
|
- 幂等键优先级:
|
||||||
|
- 优先使用第三方提供的 `event_id`。
|
||||||
|
- 若缺少 `event_id`,使用兜底组合键:`tracking_no + event_code + event_time`(必要时可补充 `node_name/location` 以降低碰撞)。
|
||||||
|
- 数据库约束:建议在事件表上建立唯一约束(例如 `carrier + tracking_no + event_id` 或 `tracking_no + dedupe_key`),写入采用 Upsert/Insert-Ignore,应用层无需“先查再写”来保证并发安全。
|
||||||
|
- 乱序处理:允许事件乱序写入(以免晚到的历史节点被丢弃);查询展示时按 `event_time` 排序生成时间线。
|
||||||
|
- 状态不回退(平台侧可选规则):当先收到终态(如 `DELIVERED`)后又收到更早时间的在途事件时,不应将订单状态从终态回退;可采用“以最新 `event_time` 事件计算 current_status”或“按状态流转等级仅允许前进”的策略。
|
||||||
|
|
||||||
|
## 八、展示与 UI 要求(与前端对接点)
|
||||||
|
- 时间线按 `event_time` 展示,标注 `carrier` 来源与 `last_synced_at`。
|
||||||
|
- 节点可展开查看 `description`、`node_name`、`location`、`evidence_urls`(签收照片/回单)。若无证据则隐藏预览。
|
||||||
|
- 提供“查看第三方原文”链接,展示 `raw_payload`(仅客服/运维可见)。
|
||||||
|
|
||||||
|
平台对客户端返回建议(安卓/Web):
|
||||||
|
- 客户端请求应面向平台统一接口(例如:`GET /api/v1/delivery/express/track?order_no=...`),由平台返回统一的 `status_history`。
|
||||||
|
- 响应中建议包含:`carrier`、`tracking_no`、`status`、`status_history`、`last_synced_at`,以及可选的 `eta`。
|
||||||
|
- `raw_payload` 不建议下发给普通用户端;仅在 Web 客服/运维界面按权限下发并记录审计。
|
||||||
|
|
||||||
|
## 九、监控与告警
|
||||||
|
- 指标:webhook 接收成功率、平均同步时延、同步失败率、超过阈值未更新的运单数。
|
||||||
|
- 告警:承运方 5xx 增加、webhook 验签失败率异常、单运单长时间无更新。
|
||||||
|
|
||||||
|
## 十、示例 cURL(Webhook 验证场景模拟)
|
||||||
|
```
|
||||||
|
curl -X POST https://api.yourplatform.com/webhook/express/status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Client-Id: carrier_yunda" \
|
||||||
|
-H "X-Timestamp: 2026-02-05T14:32:00+08:00" \
|
||||||
|
-H "X-Signature: <hmac-signature>" \
|
||||||
|
-d '{"tracking_no":"YD123456789","event_id":"e_20260205_0001","event_code":"TRANSIT","event_time":"2026-02-05T14:32:00+08:00","event_text":"运输中"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 十一、日志与保留策略
|
||||||
|
- 建议保留 `raw_payload` 与入库审计日志至少 30 天以便纠纷处理,审计记录包括接收时间、IP、头部信息与处理结果。
|
||||||
|
|
||||||
|
## 十二、兼容与扩展建议
|
||||||
|
- 建议实现承运商 Adapter 层:承运方差异在 Adapter 层转换为统一模型,便于后续扩展新承运方或替换聚合方。
|
||||||
|
- 若需签收照片或更高粒度信息,应优先与承运方签订企业级合同或直连,并在 UI 端明确注明凭证来源。
|
||||||
|
|
||||||
|
### 十二点一、平台后端适配架构(推荐实现方式)
|
||||||
|
|
||||||
|
目标:当不同第三方(直连承运方/聚合平台)API 结构、鉴权方式、事件码不同的时候,平台侧**不修改核心业务与数据库结构**即可接入。
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
- **统一领域模型入库**:第三方差异在入库前完成映射;数据库保存统一事件字段 + `raw_payload`。
|
||||||
|
- **Adapter 可插拔**:每家第三方实现一个 Adapter(验签/解析/查询/映射),核心服务只处理统一模型。
|
||||||
|
- **接入入口两条线**:Webhook(推送)与 Poller(轮询补偿)最终都走同一条 `EventIngestService`。
|
||||||
|
|
||||||
|
模块划分(建议):
|
||||||
|
- `WebhookController`:接收回调、路由到对应 Adapter、快速返回 200(业务异步处理)。
|
||||||
|
- `CarrierAdapter`(每家一个):验签、解析、字段映射、状态映射、(可选)轨迹查询。
|
||||||
|
- `AdapterRegistry/Router`:按 `carrier` 或 `X-Client-Id` 选择 Adapter(支持灰度与多配置)。
|
||||||
|
- `EventIngestService`:幂等去重、乱序处理策略、统一入库、写审计日志。
|
||||||
|
- `TrackQueryService`:给安卓/Web 提供统一查询接口(只读),不透出第三方差异。
|
||||||
|
- `PollerJob`:定时对“长时间无更新”的运单做轮询补偿(可按承运方频率配置)。
|
||||||
|
|
||||||
|
幂等与乱序建议(平台侧):
|
||||||
|
- 幂等键优先级:`event_id`(优先) > `tracking_no + event_code + event_time`(兜底)。
|
||||||
|
- 入库:允许乱序写入;查询展示时按 `event_time` 排序。
|
||||||
|
- 兼容缺字段:缺少 `event_id` 时必须使用兜底组合键;缺少 `location/node_name` 时 UI 降级仅展示 `event_text`。
|
||||||
|
|
||||||
|
### 架构图(Mermaid)
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Clients[客户端]
|
||||||
|
A[安卓 App] -->|GET 物流时间线| PAPI[平台 API]
|
||||||
|
W[Web/H5/PC] -->|GET 物流时间线| PAPI
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Platform[平台后端]
|
||||||
|
PAPI --> TQS[TrackQueryService\n统一查询]
|
||||||
|
TQS --> DB[(Logistics DB\nWaybill + TrackingEvent)]
|
||||||
|
|
||||||
|
WH[WebhookController\n接收回调] --> REG[AdapterRegistry/Router\n按carrier或X-Client-Id路由]
|
||||||
|
REG --> AD1[YUNDA Adapter]
|
||||||
|
REG --> AD2[YTO Adapter]
|
||||||
|
REG --> AD3[Aggregator Adapter\n(快递鸟/快递100)]
|
||||||
|
|
||||||
|
AD1 --> ING[EventIngestService\n幂等/映射/入库]
|
||||||
|
AD2 --> ING
|
||||||
|
AD3 --> ING
|
||||||
|
ING --> DB
|
||||||
|
|
||||||
|
PJ[PollerJob\n轮询补偿] --> REG
|
||||||
|
PJ -->|queryTrack| AD1
|
||||||
|
PJ -->|queryTrack| AD2
|
||||||
|
PJ -->|queryTrack| AD3
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ThirdParty[第三方/Mock]
|
||||||
|
MS[Mock Server 或真实承运方/聚合] -->|Webhook 推送| WH
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回调时序图(Webhook 推送 -> 入库 -> 客户端展示)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant TP as Mock/第三方
|
||||||
|
participant WH as WebhookController
|
||||||
|
participant REG as AdapterRegistry
|
||||||
|
participant AD as CarrierAdapter
|
||||||
|
participant ING as EventIngestService
|
||||||
|
participant DB as LogisticsDB
|
||||||
|
participant API as 平台查询API
|
||||||
|
participant APP as 安卓/Web
|
||||||
|
|
||||||
|
TP->>WH: POST /webhook/express/status (headers + body)
|
||||||
|
WH->>REG: resolveAdapter(carrier/X-Client-Id)
|
||||||
|
REG->>AD: getAdapter()
|
||||||
|
WH->>AD: verify + parseWebhook(rawBody)
|
||||||
|
AD-->>WH: NormalizedEvent[]
|
||||||
|
WH-->>TP: 200 OK (快速返回)
|
||||||
|
WH->>ING: ingest(NormalizedEvent[])
|
||||||
|
ING->>DB: upsert events (幂等去重)
|
||||||
|
|
||||||
|
APP->>API: GET /api/v1/delivery/express/track?order_no=...
|
||||||
|
API->>DB: query events (order_no/tracking_no)
|
||||||
|
DB-->>API: events ordered by event_time
|
||||||
|
API-->>APP: timeLine(status_history + last_synced_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
备注:Mermaid 图在不支持的 Markdown 渲染器中会降级为代码块,不影响内容阅读。
|
||||||
|
|
||||||
|
## 十三、附录:不同第三方 API 形态示例(用于理解差异)
|
||||||
|
|
||||||
|
说明:以下为“常见形态示例”,用于帮助团队理解不同第三方接口在鉴权、字段与能力上的差异。
|
||||||
|
- 不保证与任一承运方/聚合平台的官方文档 100% 一致;对接时必须以官方文档、沙箱与实际回文为准。
|
||||||
|
- 推荐做法:在平台侧实现 Adapter/Mapper,把第三方差异映射为本文定义的统一事件模型再入库。
|
||||||
|
|
||||||
|
### 13.1 聚合平台常见形态(如快递100/快递鸟类)
|
||||||
|
|
||||||
|
典型能力:
|
||||||
|
- 轨迹查询 API:传入 `company_code + tracking_no`(有些场景需要收件人手机号后四位)
|
||||||
|
- 订阅/推送:先订阅,后续以 Webhook 回调推送事件
|
||||||
|
- 鉴权:常见 `appKey + sign`(MD5/HMAC)或 Token
|
||||||
|
|
||||||
|
示例:轨迹查询(示例)
|
||||||
|
```http
|
||||||
|
POST https://api.aggregator.example.com/v1/track/query
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"company_code": "yunda",
|
||||||
|
"tracking_no": "430123456789",
|
||||||
|
"phone_last4": "8000",
|
||||||
|
"nonce": "n_123",
|
||||||
|
"timestamp": 1738735200,
|
||||||
|
"sign": "md5_or_hmac_signature"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例响应(示例)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"tracking_no": "430123456789",
|
||||||
|
"company_code": "yunda",
|
||||||
|
"state": "in_transit",
|
||||||
|
"events": [
|
||||||
|
{ "time": "2026-02-05T10:12:00+08:00", "context": "已揽收", "location": "深圳市" },
|
||||||
|
{ "time": "2026-02-05T22:01:00+08:00", "context": "到达广州分拨中心", "location": "广州市" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
差异点:
|
||||||
|
- `state` / `status` 枚举不统一;事件字段可能是 `context/desc`、`time/timestamp`。
|
||||||
|
- 不同快递公司可能要求手机号参与查询或订阅验证。
|
||||||
|
|
||||||
|
### 13.2 承运方直连常见形态(企业接口)
|
||||||
|
|
||||||
|
典型能力:
|
||||||
|
- 轨迹查询、签收回单(POD)查询可能拆成多个接口
|
||||||
|
- 鉴权更严格:HMAC、证书、IP 白名单或 OAuth2
|
||||||
|
|
||||||
|
示例:轨迹查询(示例)
|
||||||
|
```http
|
||||||
|
POST https://open.carrier.example.com/v2/route/query
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Client-Id: your_client_id
|
||||||
|
X-Timestamp: 2026-02-05T10:30:00+08:00
|
||||||
|
X-Signature: hmac_sha256(body + timestamp)
|
||||||
|
|
||||||
|
{
|
||||||
|
"tracking_no": "SF1234567890",
|
||||||
|
"include_pod": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例响应(示例)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tracking_no": "SF1234567890",
|
||||||
|
"routes": [
|
||||||
|
{ "code": "PICKED", "desc": "已揽收", "time": "2026-02-05T09:10:00+08:00", "station": "深圳XX营业点" },
|
||||||
|
{ "code": "TRANSIT", "desc": "运输中", "time": "2026-02-05T21:45:00+08:00", "station": "广州分拨中心" }
|
||||||
|
],
|
||||||
|
"pod": { "signed": false, "photo_url": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
差异点:
|
||||||
|
- 字段更细(网点编码、操作员等),但不统一;POD 能力通常受权限/合同影响。
|
||||||
|
|
||||||
|
### 13.3 Webhook 推送常见形态(订阅后回调)
|
||||||
|
|
||||||
|
典型能力:
|
||||||
|
- 订阅后由第三方主动推送轨迹事件到平台
|
||||||
|
- 具备重试机制,因此平台必须做幂等去重、乱序处理
|
||||||
|
|
||||||
|
示例:回调事件(示例,字段名因第三方而异)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tracking_no": "430123456789",
|
||||||
|
"carrier": "YUNDA",
|
||||||
|
"event_id": "evt_0001",
|
||||||
|
"event_time": "2026-02-05T22:01:00+08:00",
|
||||||
|
"event_text": "到达广州分拨中心",
|
||||||
|
"location": "广州市",
|
||||||
|
"extra": { "pod_photo": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
接入建议:
|
||||||
|
- 无论第三方字段如何变化,平台入库前统一映射到本文“核心数据模型”,并保留 `raw_payload`。
|
||||||
|
- 平台用 `event_id`(优先)或组合键(`tracking_no+event_code+event_time`)实现幂等。
|
||||||
|
|
||||||
|
### 13.4 圆通(YTO)物流轨迹查询接口(官方文档摘要)
|
||||||
|
|
||||||
|
文档入口:
|
||||||
|
- https://open.yto.net.cn/interfaceDocument/menu251/submenu258
|
||||||
|
|
||||||
|
定位:该文档描述“根据圆通运单号查询物流轨迹”的接口形态,典型属于“平台主动查询(轮询)”模式。
|
||||||
|
|
||||||
|
关键交互要点(按官方文档整理,具体以控制台配置与最新文档为准):
|
||||||
|
- 传输:HTTPS,POST。
|
||||||
|
- 报文结构:请求体包含 `timestamp`、`param`、`format`、`sign`。
|
||||||
|
- `param`:以 JSON/XML 字符串承载业务参数;轨迹查询场景下包含圆通运单号字段(示例为 `NUMBER`,一次查询一个单号)。
|
||||||
|
|
||||||
|
签名规则(按文档描述抽象):
|
||||||
|
- 参与签名的明文:`param + method + v`(其中 `method` 与 `v` 来自控制台为该接口生成的配置)。
|
||||||
|
- 将上述明文与客户密钥(`secret`)拼接后做 MD5,再对 MD5 的字节结果进行 Base64 编码,得到 `sign`。
|
||||||
|
- 伪公式:`sign = base64(md5((param + method + v) + secret))`
|
||||||
|
|
||||||
|
响应字段(按文档列举的 JSON 返回字段抽象):
|
||||||
|
- 运单号:`waybill_No`
|
||||||
|
- 走件时间:`upload_Time`(yyyy-MM-dd HH:mm:ss)
|
||||||
|
- 物流状态码:`infoContent`(示例枚举包括 GOT/ARRIVAL/DEPARTURE/SENT_SCAN/SIGNED 等)
|
||||||
|
- 物流信息文案:`processInfo`
|
||||||
|
- 城市/区县:`city`、`district`(可选)
|
||||||
|
- 重量:`weight`(可选)
|
||||||
|
|
||||||
|
平台侧映射建议(把第三方差异收敛到统一模型):
|
||||||
|
- 时间:`upload_Time` -> 平台 `event_time`
|
||||||
|
- 文案:`processInfo` -> 平台 `event_text`(可另存 `description`)
|
||||||
|
- 状态:`infoContent` -> 平台 `event_code`(保留原码),再映射到平台统一 `status_code`(见 `状态映射表.md`)
|
||||||
|
|
||||||
|
`infoContent` 到平台统一 `status_code` 的建议映射(示例):
|
||||||
|
- GOT(已揽收)-> IN_TRANSIT
|
||||||
|
- ARRIVAL(已收入/到达)-> IN_TRANSIT(中转/到达节点统一视为运输中)
|
||||||
|
- DEPARTURE(已发出/离开节点)-> IN_TRANSIT
|
||||||
|
- SENT_SCAN(派件)-> OUT_FOR_DELIVERY
|
||||||
|
- INBOUND(自提柜入柜)-> IN_TRANSIT(或按业务定义为 OUT_FOR_DELIVERY)
|
||||||
|
- SIGNED(签收成功)-> DELIVERED
|
||||||
|
- FAILED(签收失败)-> EXCEPTION
|
||||||
|
- TMS_RETURN(退回)-> RETURNED
|
||||||
|
|
||||||
|
落库建议:
|
||||||
|
- 将圆通原始返回(整个数组或单条对象)保存到 `raw_payload`,便于客服/运维对照圆通官网。
|
||||||
|
- 幂等去重:若第三方无稳定 `event_id`,可用组合键 `tracking_no + infoContent + upload_Time` 生成 `dedupe_key`。
|
||||||
|
|
||||||
|
### 13.5 韵达(YUNDA)开放平台(官方文档入口与调研清单)
|
||||||
|
|
||||||
|
文档入口:
|
||||||
|
- https://open.yundaex.com/api/apiDoc
|
||||||
|
|
||||||
|
说明:韵达开放平台文档页面存在较多交互式内容(需登录/控制台配置后才能看到每个接口的 `method/v/测试地址` 等关键信息)。因此本节先固化“对接时必须确认的要点清单”,避免对接过程中遗漏。
|
||||||
|
|
||||||
|
从官方文档导航可见的能力分类(用于判断覆盖范围):
|
||||||
|
- API 鉴权说明
|
||||||
|
- 电子面单、散件下单
|
||||||
|
- 物流轨迹
|
||||||
|
- 售后服务、国际件、基础服务等
|
||||||
|
|
||||||
|
韵达轨迹对接需要在控制台确认/落盘的信息(建议形成《承运方接入配置表》):
|
||||||
|
- 鉴权方式:签名算法(HMAC/MD5 等)、参与签名字段、编码/排序规则、是否包含时间戳与 nonce。
|
||||||
|
- 接口要素:轨迹查询接口 URL、`method`、`v`(如平台/第三方采用“method+version+param”体系)。
|
||||||
|
- 订阅/回调能力:是否支持轨迹订阅与回调推送、回调重试策略、回调验签字段。
|
||||||
|
- 返回字段:事件时间字段、事件码/状态字段、事件文案字段、地点字段(城市/网点)与可选 POD 能力。
|
||||||
|
|
||||||
|
平台侧落地方式保持不变:
|
||||||
|
- 无论韵达返回结构如何,统一通过 Adapter 映射为平台 `TrackingEvent` 领域模型入库,并保留 `raw_payload`。
|
||||||
|
|
||||||
|
附:Mock 控制面通用错误码(示例)
|
||||||
|
- 40001: invalid_parameter
|
||||||
|
- 40002: missing_required_field
|
||||||
|
- 40901: duplicate_request
|
||||||
|
- 50001: internal_error
|
||||||
|
|
||||||
|
可选增强(非本期必需):
|
||||||
|
- 生成 OpenAPI 文档(控制面 API + Webhook 示例)
|
||||||
|
- 补齐“字段必填矩阵/容错矩阵”(配合 `drop_fields` 故障注入)
|
||||||
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
|
||||||
196
pages/mall/delivery/doc/需求文档(现用)/数据库对比与修改建议.md
Normal file
196
pages/mall/delivery/doc/需求文档(现用)/数据库对比与修改建议.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# 配送端(自营骑手)表 vs 第三方快递轨迹表:对比与数据库修改建议
|
||||||
|
|
||||||
|
本文目标:
|
||||||
|
- 整理“原先配送端(自营骑手/同城配送)”使用的核心数据表。
|
||||||
|
- 对比“现在第三方快递配送(韵达/圆通等)”的轨迹/运单数据表。
|
||||||
|
- 给出建议的数据库改造方案:如何在不破坏订单主表的前提下,引入第三方快递轨迹能力,并(可选)逐步弃用原先配送端表的依赖。
|
||||||
|
|
||||||
|
明确决策(当前结论):
|
||||||
|
- 仅支持第三方快递轨迹(承运方运单 + 轨迹时间线)。
|
||||||
|
- 不做自营骑手端/同城配送任务流;`ml_delivery_drivers`/`ml_delivery_tasks` 视为历史遗留表,不再作为新能力的依赖与写入目标。
|
||||||
|
|
||||||
|
> 说明:本对比只讨论“第三方快递轨迹/运单”与“自营骑手任务”两套模型如何在数据库层共存/迁移;二者是不同业务域,不建议强行复用同一张表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 原先配送端(自营骑手)核心表(现状)
|
||||||
|
|
||||||
|
数据来源:主库 DDL(`mall_sql/schemas/complete_mall_database.sql`)与旧配送端文档(`pages/mall/delivery/doc/old/*`)。
|
||||||
|
|
||||||
|
### 1.1 `ml_delivery_drivers`(配送员信息表)
|
||||||
|
用途:记录配送员(骑手)档案与实时工作状态;用于配送端登录后的“是否可接单/是否在线”。
|
||||||
|
|
||||||
|
关键字段(节选):
|
||||||
|
- `id`:配送员 ID
|
||||||
|
- `user_id`:关联 `ak_users.id`
|
||||||
|
- `real_name`、`id_card`、`driver_license`
|
||||||
|
- `vehicle_type`、`vehicle_number`
|
||||||
|
- `service_areas`(JSONB)
|
||||||
|
- `work_status`:1 在线 / 2 忙碌 / 3 离线
|
||||||
|
- `current_lat` / `current_lng`
|
||||||
|
- `rating_avg` / `rating_count` / `order_count`
|
||||||
|
- `status`:1 正常 / 2 暂停 / 3 离职
|
||||||
|
|
||||||
|
### 1.2 `ml_delivery_tasks`(配送任务表,配送端“状态真源”)
|
||||||
|
用途:描述“一个订单被分配给某个骑手并经历接单→取货→配送→送达”的任务流。
|
||||||
|
|
||||||
|
关键字段(节选):
|
||||||
|
- 关联:
|
||||||
|
- `order_id`(UUID,**唯一**,FK 到 `ml_orders.id`)
|
||||||
|
- `driver_id`(FK 到 `ml_delivery_drivers.id`,可为空表示未接单)
|
||||||
|
- 地址与费用:
|
||||||
|
- `pickup_address`(JSONB)取货地址
|
||||||
|
- `delivery_address`(JSONB)配送地址
|
||||||
|
- `distance`、`estimated_time`、`delivery_fee`
|
||||||
|
- 任务状态:`status`(int)
|
||||||
|
- 1 待接单
|
||||||
|
- 2 已接单
|
||||||
|
- 3 取货中
|
||||||
|
- 4 配送中
|
||||||
|
- 5 已送达
|
||||||
|
- 6 配送失败
|
||||||
|
- 时间戳:`assigned_at` / `picked_at` / `delivered_at`
|
||||||
|
- 其他:`delivery_code`(取货码)、`remark`、`failure_reason`
|
||||||
|
|
||||||
|
> 注意:旧页面/旧文档里常出现 `accepted_at`、`pickup_time`、`delivered_time` 等命名,和主库 DDL 的 `assigned_at/picked_at/delivered_at` 存在差异;如果你要继续使用自营配送链路,建议在代码层做统一字段映射或做一次字段对齐迁移。
|
||||||
|
|
||||||
|
### 1.3 与订单表的关系(旧模型的关键耦合点)
|
||||||
|
- `ml_delivery_tasks.order_id` 强依赖 `ml_orders.id`,并且 **1 个订单只能有 1 条配送任务**(`order_id UNIQUE`)。
|
||||||
|
- 旧实现里常尝试把 `ml_delivery_tasks.status` 同步到 `ml_orders.order_status`,容易造成口径不一致(旧文档也提到了这种冲突风险)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现在第三方快递配送(轨迹/运单)表(目标模型)
|
||||||
|
|
||||||
|
数据来源:`pages/mall/delivery/doc/需求文档/express_tracking_mock_platform.sql`。
|
||||||
|
|
||||||
|
> 这套表的定位不是“骑手任务流”,而是“第三方承运的运单 + 轨迹事件时间线”。
|
||||||
|
|
||||||
|
### 2.1 `platform_express_waybills`(平台侧:运单主表)
|
||||||
|
用途:一条运单(一个 `tracking_no`)的聚合信息;用于订单详情页展示“承运方/运单号/当前状态/最后同步时间”。
|
||||||
|
|
||||||
|
关键字段(节选):
|
||||||
|
- 关联:`order_id`(可选)、`order_no`(可选)
|
||||||
|
- 运单:`carrier`、`tracking_no`、`source`
|
||||||
|
- 聚合状态:`current_status_code`、`current_status_text`
|
||||||
|
- 时间:`eta`、`last_synced_at`
|
||||||
|
- 约束:`UNIQUE (carrier, tracking_no)`
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 同一个订单允许对应多条运单(拆包裹/分批发货)——因为 `order_id` 不唯一。
|
||||||
|
|
||||||
|
### 2.2 `platform_express_tracking_events`(平台侧:轨迹事件表)
|
||||||
|
用途:时间线的主数据来源;用于前端展示、告警统计与排障。
|
||||||
|
|
||||||
|
关键字段(节选):
|
||||||
|
- 关联:`waybill_id`(FK 到 `platform_express_waybills.id`)
|
||||||
|
- 事件:`event_id`(第三方可能缺失)、`event_time`、`event_code`、`event_text`
|
||||||
|
- 平台统一状态:`status_code`
|
||||||
|
- 节点:`node_name`、`location`、`description`
|
||||||
|
- 证据:`evidence_urls`(jsonb)
|
||||||
|
- 原始回文:`raw_payload`(jsonb,可用于审计/排障)
|
||||||
|
- 幂等:`dedupe_key` + `UNIQUE (waybill_id, dedupe_key)`
|
||||||
|
|
||||||
|
### 2.3 `platform_express_event_raw`(平台侧:原始接收表)
|
||||||
|
用途:记录 webhook/轮询的原始请求、验签与解析错误,便于排障与审计。
|
||||||
|
|
||||||
|
### 2.4 `mock_*`(Mock 承运方侧表)
|
||||||
|
这部分只用于联调/回归/故障注入,不建议进入生产主库的核心业务 schema(可以放在独立 schema 或测试库)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 两套模型的核心差异(为什么不建议“复用一张表”)
|
||||||
|
|
||||||
|
| 维度 | 自营骑手(旧配送端) | 第三方快递(新模型) |
|
||||||
|
|---|---|---|
|
||||||
|
| 业务对象 | 任务(骑手接单、取货、送达) | 运单(承运方扫描轨迹) |
|
||||||
|
| 与订单关系 | 1 订单 = 1 任务(`order_id UNIQUE`) | 1 订单 = N 运单(拆包裹常见) |
|
||||||
|
| 状态来源 | 平台自己驱动状态机(按钮/操作) | 第三方事件驱动(webhook/轮询) |
|
||||||
|
| 数据颗粒度 | 少量节点(接单/取货/送达) | 高频节点(到站/发车/分拣/派送/签收…) |
|
||||||
|
| 关键字段 | `driver_id`、地址 JSON、配送费、距离 | `carrier`、`tracking_no`、事件时间线、幂等去重 |
|
||||||
|
| 风险点 | 并发抢单、任务状态与订单状态不同步 | 乱序/重复事件、验签、防重放、脱敏 |
|
||||||
|
|
||||||
|
结论:
|
||||||
|
- 旧表(`ml_delivery_*`)适合“自营骑手/同城配送”。
|
||||||
|
- 新表(`platform_express_*`)适合“第三方快递轨迹”。
|
||||||
|
- 两者可以共存,但不要强行把第三方轨迹塞进 `ml_delivery_tasks`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐的数据库修改方案(兼容、可渐进)
|
||||||
|
|
||||||
|
### 4.1 方案 1(推荐):新增第三方快递表,不改旧配送表
|
||||||
|
适用:你们当前要做第三方快递展示,且已明确“不做自营骑手”。
|
||||||
|
|
||||||
|
做法:
|
||||||
|
1) 在主库新增(或通过 migration 引入)三张平台侧表:
|
||||||
|
- `platform_express_waybills`
|
||||||
|
- `platform_express_tracking_events`
|
||||||
|
- `platform_express_event_raw`
|
||||||
|
2) 商家发货时写入/绑定:创建 `platform_express_waybills` 行(填 `order_id`、`order_no`、`carrier`、`tracking_no`)。
|
||||||
|
3) 第三方回调/轮询入库:写 `platform_express_tracking_events`,并更新 `platform_express_waybills.current_status_*` 与 `last_synced_at`。
|
||||||
|
|
||||||
|
建议的小幅增强(强烈建议做):
|
||||||
|
- 给 `platform_express_waybills(order_id)` 增加索引(便于按订单查运单)。
|
||||||
|
- 让 `platform_express_waybills.order_id` 建外键到 `ml_orders(id)`(如果你们能保证订单一定存在)。
|
||||||
|
|
||||||
|
### 4.2 方案 2:逐步“弃用旧配送端表”的依赖(当你们全面第三方快递时)
|
||||||
|
适用:你们不再提供自营骑手配送,或者想把骑手端做成独立项目。
|
||||||
|
|
||||||
|
做法(建议按阶段,不要硬删表):
|
||||||
|
- 阶段 A:停止业务写入 `ml_delivery_tasks`(页面/接口不再创建任务)。
|
||||||
|
- 阶段 B:订单详情页的物流展示只依赖 `platform_express_*`。
|
||||||
|
- 阶段 C:将 `ml_delivery_tasks` 标为 legacy(只读保留一段时间),最终再评估是否归档/删除。
|
||||||
|
|
||||||
|
为什么不建议立刻删:
|
||||||
|
- 历史数据、结算对账、纠纷复盘可能仍需要旧数据。
|
||||||
|
|
||||||
|
在“不做自营骑手”的前提下,最小要求:
|
||||||
|
- 新增第三方快递表后,业务代码只写入/读取 `platform_express_*`,不要再创建/更新 `ml_delivery_tasks`。
|
||||||
|
- 旧表保留只读(或仅用于历史查询/清理脚本),后续再评估归档策略。
|
||||||
|
|
||||||
|
### 4.3 如果两种配送方式要并存(同城骑手 + 快递)
|
||||||
|
建议用“履约类型”做分流,而不是混表:
|
||||||
|
- 同城/自营:继续用 `ml_delivery_tasks`(任务流)
|
||||||
|
- 快递:用 `platform_express_*`(运单轨迹)
|
||||||
|
- 订单详情页按订单的履约类型选择展示模块(或同时展示多个包裹)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 迁移/改库执行清单(建议写成 migration)
|
||||||
|
|
||||||
|
### 5.1 建表落库
|
||||||
|
- 把 `express_tracking_mock_platform.sql` 的 A 部分(platform)整理为一份 migration(放到 `mall_sql/migrations/`),避免:
|
||||||
|
- 重复创建 `set_updated_at()`(项目若已有同名函数)
|
||||||
|
- 把 `mock_*` 测试表混进生产 schema
|
||||||
|
|
||||||
|
### 5.2 对接 `ml_orders`
|
||||||
|
- 建议:
|
||||||
|
- `platform_express_waybills.order_id REFERENCES public.ml_orders(id)`
|
||||||
|
- `CREATE INDEX ... ON platform_express_waybills(order_id)`
|
||||||
|
- 不建议:
|
||||||
|
- 把运单号/承运方直接塞进 `ml_orders`(会导致一单多包裹很难处理)
|
||||||
|
|
||||||
|
### 5.3 数据回填(可选)
|
||||||
|
如果你们历史上已经保存过“承运方/运单号”在别处(例如订单扩展表/发货日志),可做一次性回填:
|
||||||
|
- 按订单生成 waybill 行
|
||||||
|
- 再按运单触发一次轨迹拉取
|
||||||
|
|
||||||
|
### 5.4 权限与隐私
|
||||||
|
- 买家/商家/客服看到的轨迹必须“同源”,但展示要按角色脱敏;敏感字段(如手机号、原始回文)按文档约束控制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 你接下来要怎么改数据库(最短路线)
|
||||||
|
|
||||||
|
如果你现在的目标是:商家可选承运方 + 录入运单号 + 用户能看轨迹时间线。
|
||||||
|
|
||||||
|
最短路线:
|
||||||
|
1) 在主库落 `platform_express_waybills` / `platform_express_tracking_events` / `platform_express_event_raw`
|
||||||
|
2) 发货绑定时写 `platform_express_waybills(order_id, order_no, carrier, tracking_no)`
|
||||||
|
3) 第三方事件入库写 `platform_express_tracking_events`
|
||||||
|
4) 订单详情页按 `order_id` 查 waybills,再查 events 渲染时间线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
(如你确认要我进一步把“建议的小幅增强”直接写成 SQL migration 文件,我可以在 `mall_sql/migrations/` 里新增一份只包含 platform 表的 migration,并确保不引入 `mock_*` 表。)
|
||||||
234
pages/mall/delivery/doc/需求文档(现用)/物流消息推送方案_用户端与商家端.md
Normal file
234
pages/mall/delivery/doc/需求文档(现用)/物流消息推送方案_用户端与商家端.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# 物流消息推送方案(用户端 + 商家端)
|
||||||
|
|
||||||
|
日期:2026-02-11
|
||||||
|
|
||||||
|
## 0. 文档定位
|
||||||
|
本方案用于把“第三方快递轨迹(platform_express_*)”与移动端推送(uni-push2)结合,向:
|
||||||
|
- **用户端(C 端)**:推送订单物流节点更新,并在消息中心可回溯、可标记已读。
|
||||||
|
- **商家端(B 端)**:推送与发货/履约强相关的物流节点更新(重点关注异常/签收等),并在商家消息中心可回溯。
|
||||||
|
|
||||||
|
约束与口径继承自本目录既有文档:
|
||||||
|
- 统一事件模型、字段契约、状态映射:见接口与字段相关文档。
|
||||||
|
- 敏感信息(raw_payload、完整手机号/地址、POD 等)必须按角色授权,**不得通过推送明文下发**。
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
### 1.1 背景
|
||||||
|
配送模块的事实数据源是第三方轨迹事件流:Webhook/轮询接收后入库到 `platform_express_*` 三表。
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
1) **及时性**:当运单产生关键节点时,用户/商家能收到提醒。
|
||||||
|
2) **一致性**:推送只是“变更通知”,最终展示以平台查询接口返回的 `status_history` 为准。
|
||||||
|
3) **可追溯**:所有推送对应的“物流消息”可在消息中心列表查询,并可已读/未读统计。
|
||||||
|
4) **合规与隐私**:推送 payload 与通知文案不包含敏感信息,不透传 raw。
|
||||||
|
|
||||||
|
### 1.3 不在本方案范围
|
||||||
|
- 自营骑手/同城配送任务(`ml_delivery_*`)的接单/导航/送达链路。
|
||||||
|
- 厂商通道(小米/华为等)离线能力细节(可在 uni-push2 联调文档基础上扩展)。
|
||||||
|
|
||||||
|
## 2. 数据源与现有模型复用
|
||||||
|
### 2.1 事实数据表(生产口径)
|
||||||
|
- `platform_express_waybills`:运单摘要(current_status_*、last_synced_at)。
|
||||||
|
- `platform_express_tracking_events`:轨迹事件事实表(event_time/event_text/status_code 等)。
|
||||||
|
- `platform_express_event_raw`:原始留痕与验签审计(仅运维/客服可见)。
|
||||||
|
|
||||||
|
结论:
|
||||||
|
- **消息生成以 `platform_express_tracking_events` 的新增事件为触发**。
|
||||||
|
- **通知展示以 `platform_express_waybills.current_status_code/text` + 最新事件摘要为基础**。
|
||||||
|
|
||||||
|
### 2.2 统一状态码(必须遵循)
|
||||||
|
使用平台统一 `status_code`:
|
||||||
|
`ORDER_PLACED / SHIPPED / IN_TRANSIT / OUT_FOR_DELIVERY / READY_FOR_PICKUP / DELIVERED / EXCEPTION / RETURNED`
|
||||||
|
|
||||||
|
映射规则来自“状态映射表”,平台内部应保证确定性。
|
||||||
|
|
||||||
|
## 3. 总体架构(事件驱动 + 推送唤醒刷新)
|
||||||
|
|
||||||
|
### 3.1 核心原则
|
||||||
|
- **Push ≠ 数据源**:Push 仅承载“发生了什么/在哪里看”,不承载完整时间线。
|
||||||
|
- **App 打开详情页时以接口/DB查询为准**:避免推送丢失、乱序导致前端状态不一致。
|
||||||
|
|
||||||
|
### 3.2 推荐链路
|
||||||
|
1) 第三方回调/轮询到达平台(Webhook handler / polling job)
|
||||||
|
2) 平台:验签、防重放、幂等去重、乱序入库
|
||||||
|
3) 平台:基于“新插入事件”计算是否需要生成物流消息(对 C/B 端分别判断)
|
||||||
|
4) 平台:写入“物流消息表”(用于消息中心/未读)
|
||||||
|
5) 平台:按收件人设备 CID 列表调用 uni-push2 发送通知/透传
|
||||||
|
6) 客户端:收到推送 -> 更新消息中心未读角标/缓存 -> 用户点击进入订单/物流详情 -> 拉取最新时间线
|
||||||
|
|
||||||
|
## 4. 推送场景清单(C 端 + B 端)
|
||||||
|
|
||||||
|
### 4.1 场景分级(建议最小可用)
|
||||||
|
为了避免 IN_TRANSIT 等高频节点造成骚扰,MVP 建议:
|
||||||
|
- **只对“状态级别变化”或“关键状态”推送**,并允许后续配置化扩展。
|
||||||
|
|
||||||
|
### 4.2 用户端(C 端)推送场景
|
||||||
|
对单个订单/运单,建议推送:
|
||||||
|
- `SHIPPED`:商家已发货/已绑定运单
|
||||||
|
- `OUT_FOR_DELIVERY`:派送中
|
||||||
|
- `READY_FOR_PICKUP`:待取件
|
||||||
|
- `DELIVERED`:已签收
|
||||||
|
- `EXCEPTION`:异常(破损/拒收/地址不详等)
|
||||||
|
- `RETURNED`:退回/退件
|
||||||
|
|
||||||
|
可选(非必须、后续再做):
|
||||||
|
- `IN_TRANSIT`:仅在“跨天/关键节点”或“长时间无更新后恢复更新”时推送。
|
||||||
|
|
||||||
|
### 4.3 商家端(B 端)推送场景
|
||||||
|
商家端更关注履约风险与闭环结果,建议推送:
|
||||||
|
- `SHIPPED`:发货成功(用于确认运单绑定成功/进入履约)
|
||||||
|
- `DELIVERED`:已签收(用于完成售后风险关闭)
|
||||||
|
- `EXCEPTION`:异常(优先级最高)
|
||||||
|
- `RETURNED`:退回/退件
|
||||||
|
|
||||||
|
可选(按业务需要):
|
||||||
|
- `OUT_FOR_DELIVERY`:派送中(对高价值订单可能有意义)
|
||||||
|
|
||||||
|
### 4.4 同一事件对不同端的文案差异
|
||||||
|
- 同一事件事实字段同源(event_time/event_text/status_code)。
|
||||||
|
- **C 端**文案可更贴近用户体验(避免网点内部信息、避免电话明文)。
|
||||||
|
- **B 端**文案以进度/风险为主(异常/退回明确提示)。
|
||||||
|
|
||||||
|
## 5. 消息生成规则(幂等 + 去噪)
|
||||||
|
|
||||||
|
### 5.1 生成触发点
|
||||||
|
在平台完成 `platform_express_tracking_events` 插入成功后触发(建议异步队列/任务)。
|
||||||
|
|
||||||
|
### 5.2 幂等与去重
|
||||||
|
事件表已有 `(waybill_id, dedupe_key)` 唯一约束;消息层建议再做一层幂等:
|
||||||
|
- 推荐幂等键:`recipient_scope + recipient_id + waybill_id + status_code + event_time`
|
||||||
|
- 若第三方提供 `event_id`,可用:`recipient_scope + recipient_id + waybill_id + event_id`
|
||||||
|
|
||||||
|
保证:同一条事件重试/重复推送不会造成消息中心重复。
|
||||||
|
|
||||||
|
### 5.3 去噪(MVP)
|
||||||
|
- 默认仅对 4.2/4.3 列出的状态集合生成消息。
|
||||||
|
- 同状态多事件:只推“最新 event_time”对应的一条(或按节点去重)。
|
||||||
|
|
||||||
|
## 6. Push payload 规范(建议)
|
||||||
|
|
||||||
|
### 6.1 设计目标
|
||||||
|
- **足够定位**:能让客户端知道要刷新哪个订单/运单、点击去哪里。
|
||||||
|
- **不泄露敏感**:不含手机号/地址/raw_payload。
|
||||||
|
- **可审计**:含平台侧 message_id / request_id。
|
||||||
|
|
||||||
|
### 6.2 字段建议(透传/通知通用)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"biz": "express",
|
||||||
|
"aud": "user",
|
||||||
|
"message_id": "msg_xxx",
|
||||||
|
"order_no": "ORD_2026...",
|
||||||
|
"carrier": "YTO",
|
||||||
|
"tracking_no": "YT123...",
|
||||||
|
"status_code": "OUT_FOR_DELIVERY",
|
||||||
|
"event_time": "2026-02-05T14:32:00+08:00",
|
||||||
|
"event_text": "快件正在派送中",
|
||||||
|
"deeplink": {
|
||||||
|
"path": "/pages/order/detail",
|
||||||
|
"query": {"order_no":"ORD_2026...", "tab":"logistics"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
说明:
|
||||||
|
- `event_text` 推荐使用“清洗后的文案”(避免把第三方原文中的手机号明文透传)。
|
||||||
|
- `aud`:区分用户端/商家端,有利于同一 App 多角色或同一推送通道多消费方。
|
||||||
|
|
||||||
|
### 6.3 通知栏标题/摘要建议(示例)
|
||||||
|
- 用户端:
|
||||||
|
- 标题:`物流更新` / `订单物流更新`
|
||||||
|
- 内容:`【派送中】快件正在派送中`
|
||||||
|
- 商家端:
|
||||||
|
- 标题:`订单履约提醒`
|
||||||
|
- 内容:`【异常】包裹地址不详正在退回`(异常优先级最高)
|
||||||
|
|
||||||
|
## 7. 消息中心与未读(服务端为准)
|
||||||
|
|
||||||
|
### 7.1 为什么需要消息中心
|
||||||
|
- Push 可能丢失/被系统拦截;消息中心提供可回溯记录。
|
||||||
|
- 多设备登录需要统一未读。
|
||||||
|
|
||||||
|
### 7.2 建议的数据模型(平台侧新增)
|
||||||
|
建议新增两类表(命名仅示例):
|
||||||
|
1) 设备注册表 `push_devices`
|
||||||
|
- `id`
|
||||||
|
- `user_id` / `merchant_id`(二选一或用统一 subject)
|
||||||
|
- `cid`
|
||||||
|
- `platform`(android/ios/web)
|
||||||
|
- `appid`(用于区分环境)
|
||||||
|
- `last_seen_at`、`is_active`
|
||||||
|
|
||||||
|
2) 物流消息表 `express_notifications`
|
||||||
|
- `id`(message_id)
|
||||||
|
- `aud`(user/merchant)
|
||||||
|
- `recipient_id`
|
||||||
|
- `order_id/order_no`、`waybill_id`、`tracking_no`、`carrier`
|
||||||
|
- `status_code`、`event_time`、`event_text_safe`
|
||||||
|
- `read_at`(或拆到 read 表做多端多设备)
|
||||||
|
- `created_at`
|
||||||
|
- 幂等键(unique)
|
||||||
|
|
||||||
|
### 7.3 API(建议)
|
||||||
|
- 设备注册:`POST /api/v1/push/register`(登录后上报 CID,绑定到账号)
|
||||||
|
- 设备解绑:`POST /api/v1/push/unregister`
|
||||||
|
- 消息列表:`GET /api/v1/notifications?biz=express&aud=user|merchant&page=...`
|
||||||
|
- 标记已读:`POST /api/v1/notifications/read`(按 message_id 或按订单批量)
|
||||||
|
- 未读数:`GET /api/v1/notifications/unread-count?biz=express&aud=...`
|
||||||
|
|
||||||
|
## 8. 点击跳转(deeplink)
|
||||||
|
|
||||||
|
### 8.1 跳转原则
|
||||||
|
- 点击通知后进入“订单详情-物流区块”或“物流详情页”。
|
||||||
|
- 页面打开后必须调用平台查询接口刷新 `status_history`。
|
||||||
|
|
||||||
|
### 8.2 跳转目标建议
|
||||||
|
- 用户端:订单详情页(展示物流时间线)
|
||||||
|
- 商家端:订单详情页(展示物流时间线 + 发货信息)
|
||||||
|
- 平台后台:不通过 App push 跳转(后台有自己的排障页)
|
||||||
|
|
||||||
|
## 9. 安全、权限与隐私
|
||||||
|
|
||||||
|
### 9.1 收件人权限校验
|
||||||
|
- 用户端:仅能收到/查看自己订单的物流消息。
|
||||||
|
- 商家端:仅能收到/查看自己店铺订单的物流消息。
|
||||||
|
|
||||||
|
服务端生成消息时必须校验:`order_no -> 订单归属 -> recipient`。
|
||||||
|
|
||||||
|
### 9.2 敏感字段控制(强约束)
|
||||||
|
- 禁止通过 push payload/通知文案下发:完整手机号、完整地址、raw_payload、签名/密钥。
|
||||||
|
- `raw_payload` 仅允许客服/运维在后台折叠查看,并记录审计。
|
||||||
|
|
||||||
|
### 9.3 CID 绑定安全
|
||||||
|
- CID 必须在**登录态**下上报并绑定到账号。
|
||||||
|
- 更换设备/卸载重装:CID 可能变化,需更新绑定。
|
||||||
|
- 退出登录时建议解绑或标记设备 inactive。
|
||||||
|
|
||||||
|
## 10. 失败与补偿
|
||||||
|
- 推送失败(CID 失效/不属于应用):标记设备 inactive 并等待下次登录刷新。
|
||||||
|
- Webhook 入库成功但推送失败:不影响事实数据;消息中心仍可查询到记录(若已写入消息表)。
|
||||||
|
- 客户端未收到 push:用户进入订单详情时仍能通过查询接口看到最新轨迹。
|
||||||
|
|
||||||
|
## 11. 验收清单(与联调口径一致)
|
||||||
|
1) 插入一条轨迹事件(模拟 webhook)后:
|
||||||
|
- waybill `current_status_code/text` 更新
|
||||||
|
- events 表新增且幂等
|
||||||
|
- 消息中心新增 1 条(不重复)
|
||||||
|
- 目标端(用户/商家)收到推送
|
||||||
|
|
||||||
|
2) 乱序/重复推送:
|
||||||
|
- 事件表不重复、消息中心不重复
|
||||||
|
- 当前状态不回退(按平台选定算法)
|
||||||
|
|
||||||
|
3) 隐私校验:
|
||||||
|
- push 文案与 payload 无手机号明文/无 raw
|
||||||
|
|
||||||
|
4) 点击跳转:
|
||||||
|
- 落到订单/物流详情页并刷新出最新时间线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 与本目录文档的对应关系(索引)
|
||||||
|
- 统一模型与 Webhook 约束:接口规范
|
||||||
|
- 状态映射:状态映射表
|
||||||
|
- 展示字段与隐私规则:前端字段清单
|
||||||
|
- 生产表结构与幂等/乱序:生产表说明_platform_express
|
||||||
|
- 移动端 push 联调与 CID 归属:uni-push2 安卓联调与取 CID 说明
|
||||||
71
pages/mall/delivery/doc/需求文档(现用)/状态映射表.md
Normal file
71
pages/mall/delivery/doc/需求文档(现用)/状态映射表.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 状态映射表(第三方/Mock -> 平台统一状态)
|
||||||
|
|
||||||
|
本表用于将第三方(或 Mock Server)产生的 `event_code/event_text` 映射为平台统一 `status_code`,以保证前端时间线与告警规则可复用。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `接口规范.md`:统一事件模型与接入模式
|
||||||
|
- `前端字段清单.md`:时间线字段契约(status_history/status_code)
|
||||||
|
|
||||||
|
平台统一状态(建议,尽量少且稳定):
|
||||||
|
- ORDER_PLACED:已下单(平台侧订单状态;通常不是第三方快递事件)
|
||||||
|
- SHIPPED:已发货(已绑定运单/待揽收)
|
||||||
|
- IN_TRANSIT:运输中(含“中转中/发往下一站/分拨/到达节点/在途”等)
|
||||||
|
- OUT_FOR_DELIVERY:派送中
|
||||||
|
- READY_FOR_PICKUP:待取件(到驿站/自提柜等,等待收件人取件)
|
||||||
|
- DELIVERED:已签收(含本人签收/代收点签收)
|
||||||
|
- EXCEPTION:异常(地址不详、拒收、破损、丢件、派送失败等)
|
||||||
|
- RETURNED:退回/退件
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `ORDER_PLACED` 通常来自平台订单系统(下单成功),不一定存在 `tracking_no`;在“统一时间线展示”场景下可作为平台生成事件出现在物流时间线里。
|
||||||
|
- 第三方事件原文(`event_text`)尽量保留;平台只用 `status_code` 做标签/筛选/告警。
|
||||||
|
|
||||||
|
第三方/Mock 事件码(建议) -> 平台统一状态(示例映射)
|
||||||
|
- ORDERED / CREATED -> SHIPPED(若该事件表示“快递单已创建/已出库”,而非平台下单)
|
||||||
|
- PICKED / COLLECTED -> IN_TRANSIT(已揽收后进入物流网络)
|
||||||
|
- ARRIVED_HUB / ARRIVAL -> IN_TRANSIT(中转/到达节点统一视为运输中)
|
||||||
|
- DEPARTED_HUB / TRANSIT / IN_TRANSIT -> IN_TRANSIT
|
||||||
|
- ARRIVED_DEST_CITY -> IN_TRANSIT
|
||||||
|
- OUT_FOR_DELIVERY -> OUT_FOR_DELIVERY
|
||||||
|
- AT_PICKUP_POINT / READY_FOR_PICKUP / DELIVERED_TO_PICKUP -> READY_FOR_PICKUP
|
||||||
|
- SIGNED / DELIVERED -> DELIVERED
|
||||||
|
- FAILED_DELIVERY / REJECTED / ADDRESS_INVALID / DAMAGED / LOST -> EXCEPTION
|
||||||
|
- RETURNED / RETURNING -> RETURNED
|
||||||
|
|
||||||
|
平台生成事件(用于统一时间线,可选) -> 平台统一状态
|
||||||
|
- ORDER_PLACED -> ORDER_PLACED
|
||||||
|
- MERCHANT_CONFIRMED_SHIPMENT / SHIPPED -> SHIPPED
|
||||||
|
|
||||||
|
示例(用于 UI 文案)
|
||||||
|
- IN_TRANSIT:运输中
|
||||||
|
- OUT_FOR_DELIVERY:快件正在派送中
|
||||||
|
- SIGNED:客户已签收(可带 POD 图片)
|
||||||
|
|
||||||
|
注意事项
|
||||||
|
- 平台入库请保留原始 `event_text` 与 `raw_payload`,前端默认展示 `event_text`,状态标签使用 `status_code`。
|
||||||
|
- Mock Server 应支持故障注入(重复/乱序/延迟/验签失败),平台状态映射必须在幂等入库后执行。
|
||||||
|
|
||||||
|
为尽量保证事件状态保持一致(基础要求):
|
||||||
|
- `event_code/event_text` 视为第三方“事实记录”,尽量不改写;统一口径(标签、颜色、筛选)交由 `status_code` 承担。
|
||||||
|
- 映射必须确定性:同一承运方、同一 `event_code` 在同一套映射规则下应得到同一 `status_code`。
|
||||||
|
|
||||||
|
备选方案:映射版本化(`mapping_version`)(可选增强)
|
||||||
|
|
||||||
|
为什么要做:
|
||||||
|
- 防止“状态漂移”:映射规则一旦调整,历史订单的已入库事件如果重新计算 `status_code`,可能出现“昨天运输中,今天变异常/又变回运输中”,引发用户与客服争议。
|
||||||
|
- 提升可审计性:当出现纠纷或对账问题时,需要能回答“当时按哪一版规则映射出来的”。
|
||||||
|
- 降低排障成本:多承运方/多渠道(直连+聚合)并存时,版本信息能快速定位是“第三方事件变了”还是“平台映射变了”。
|
||||||
|
|
||||||
|
什么时候启用:
|
||||||
|
- 状态映射会随运营/产品频繁调整,且希望历史展示稳定、可解释。
|
||||||
|
- 客服/售后对轨迹状态一致性有明确 SLA 或合规/审计要求。
|
||||||
|
|
||||||
|
怎么落地(从轻到重):
|
||||||
|
- 轻量做法(推荐起步):将映射表作为配置/代码资产管理,使用 Git tag/发布号作为 `mapping_version`,并在发布记录中保留变更原因与生效时间。
|
||||||
|
- 加强做法:在事件入库时把 `mapping_version` 一并存入事件记录(或运单聚合记录),对外查询接口可返回该版本用于排查。
|
||||||
|
- 变更策略建议:
|
||||||
|
- 小改动优先“仅影响未来事件”(不回刷历史)。
|
||||||
|
- 如必须修正历史映射,采用回放/回填任务,并对已对外展示的结果做备注或审计记录,避免静默改写。
|
||||||
|
|
||||||
|
代价与取舍:
|
||||||
|
- 会引入配置治理与发布流程的管理成本;若当前映射很稳定、承运方少、无强审计诉求,可以先不启用该方案。
|
||||||
148
pages/mall/delivery/doc/需求文档(现用)/生产表说明_platform_express.md
Normal file
148
pages/mall/delivery/doc/需求文档(现用)/生产表说明_platform_express.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 生产表说明:platform_express_*(第三方快递轨迹平台侧)
|
||||||
|
|
||||||
|
本文档对应生产迁移脚本:`pages/mall/delivery/doc/需求文档/express_tracking_platform_upgrade.sql`。
|
||||||
|
|
||||||
|
## 1. 设计目标与边界
|
||||||
|
|
||||||
|
- 目标:为第三方承运(韵达/圆通/中通等)提供统一的“运单 + 轨迹事件 + 原始回调留痕”入库模型,支撑用户端/商家端/平台后台同源展示。
|
||||||
|
- 边界:
|
||||||
|
- 仅覆盖第三方快递轨迹,不包含自营骑手/配送任务(`ml_delivery_*`)。
|
||||||
|
- 生产仅使用 `platform_express_*` 三表;不包含任何 `mock_*` 测试表。
|
||||||
|
|
||||||
|
## 2. 表关系概览
|
||||||
|
|
||||||
|
- `platform_express_waybills`(运单主表)
|
||||||
|
- 1 条运单对应 N 条轨迹事件(`platform_express_tracking_events`)。
|
||||||
|
- `platform_express_tracking_events`(轨迹事件事实表)
|
||||||
|
- 时间线展示、状态判断、告警统计的主来源。
|
||||||
|
- 对同一运单使用 `(waybill_id, dedupe_key)` 做幂等去重。
|
||||||
|
- `platform_express_event_raw`(原始接收留痕表)
|
||||||
|
- 记录每一次 webhook/轮询/人工补录的原始请求/响应内容与验签结果、解析错误,用于审计与排障。
|
||||||
|
|
||||||
|
## 3. 字段说明
|
||||||
|
|
||||||
|
### 3.1 `public.platform_express_waybills`(运单主表)
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 以 `(carrier, tracking_no)` 为唯一键管理“运单实体”,可选关联订单;承载“当前状态/预计到达/最近同步时间”等运单级摘要信息。
|
||||||
|
|
||||||
|
约束/触发器/索引:
|
||||||
|
- 唯一约束:`uk_platform_express_waybill (carrier, tracking_no)`
|
||||||
|
- 外键:`order_id -> public.ml_orders(id)`,`ON DELETE SET NULL`
|
||||||
|
- `updated_at` 触发器:`trigger_platform_express_waybills_updated_at`(UPDATE 时自动刷新)
|
||||||
|
- 常用索引:
|
||||||
|
- `idx_platform_express_waybills_order_id (order_id)`
|
||||||
|
- `idx_platform_express_waybills_order_no (order_no)`
|
||||||
|
- `idx_platform_express_waybills_tracking_no (tracking_no)`
|
||||||
|
- `idx_platform_express_waybills_status (current_status_code)`
|
||||||
|
|
||||||
|
字段表:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| id | UUID | 否 | PK,`uuid_generate_v4()` | 运单主键 |
|
||||||
|
| order_id | UUID | 是 | FK -> `ml_orders(id)`,删除置空 | 关联订单主键(推荐) |
|
||||||
|
| order_no | VARCHAR(64) | 是 | | 订单号(展示/兜底,用于无法拿到 `order_id` 的场景) |
|
||||||
|
| carrier | VARCHAR(32) | 否 | 参与唯一约束 | 承运方编码(如 `YUNDA`/`YTO`/`ZTO` 等;也可接入聚合方编码) |
|
||||||
|
| tracking_no | VARCHAR(64) | 否 | 参与唯一约束 | 运单号/快递单号 |
|
||||||
|
| source | VARCHAR(16) | 否 | 默认 `'mock'` | 运单数据来源:`mock`/`carrier`/`aggregator` 等 |
|
||||||
|
| current_status_code | VARCHAR(32) | 否 | 默认 `'SHIPPED'` | 运单当前平台状态(用于列表页/摘要),通常由最新事件映射得到 |
|
||||||
|
| current_status_text | TEXT | 是 | | 运单当前状态文本(可为最新事件文本的“清洗版”或平台自定义) |
|
||||||
|
| eta | TIMESTAMPTZ | 是 | | 预计送达时间(可选;来自承运方/聚合方或平台预测) |
|
||||||
|
| last_synced_at | TIMESTAMPTZ | 是 | | 最近一次与承运方/聚合方同步时间(用于健康度与补偿轮询判断) |
|
||||||
|
| created_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 创建时间 |
|
||||||
|
| updated_at | TIMESTAMPTZ | 否 | 默认 `NOW()`;触发器维护 | 更新时间(UPDATE 自动刷新) |
|
||||||
|
|
||||||
|
|
||||||
|
### 3.2 `public.platform_express_tracking_events`(轨迹事件表)
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 存储每一个轨迹节点(揽收/到站/运输中/派送/签收/异常等),作为时间线展示与状态机判断的事实来源。
|
||||||
|
- 保留第三方原文(`event_code`/`event_text`/`raw_payload`),同时写入平台统一状态(`status_code`)。
|
||||||
|
- 通过 `dedupe_key` 实现幂等:重复回调/轮询不会重复插入。
|
||||||
|
|
||||||
|
约束/索引:
|
||||||
|
- 外键:`waybill_id -> platform_express_waybills(id)`,`ON DELETE CASCADE`
|
||||||
|
- 唯一约束:`uk_platform_express_event_dedupe (waybill_id, dedupe_key)`
|
||||||
|
- 常用索引:
|
||||||
|
- `idx_platform_express_events_waybill_time (waybill_id, event_time)`(按运单查时间线)
|
||||||
|
- `idx_platform_express_events_tracking_time (tracking_no, event_time)`(按运单号查)
|
||||||
|
- `idx_platform_express_events_status (status_code)`(按状态统计/筛选)
|
||||||
|
- `idx_platform_express_events_received_at (received_at)`(按接收时间排障)
|
||||||
|
- `gin_platform_express_events_raw_payload`(`raw_payload` JSONB GIN,便于排障检索)
|
||||||
|
|
||||||
|
字段表:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| id | UUID | 否 | PK,`uuid_generate_v4()` | 事件主键 |
|
||||||
|
| waybill_id | UUID | 否 | FK,级联删除 | 所属运单 |
|
||||||
|
| carrier | VARCHAR(32) | 否 | | 承运方编码(冗余字段,便于查询/对账) |
|
||||||
|
| tracking_no | VARCHAR(64) | 否 | | 运单号(冗余字段,便于查询/对账) |
|
||||||
|
| event_id | VARCHAR(128) | 是 | | 第三方事件唯一 ID(有些承运方会提供;优先用于幂等) |
|
||||||
|
| event_time | TIMESTAMPTZ | 否 | | 事件发生时间(时间线排序的主依据) |
|
||||||
|
| event_code | VARCHAR(64) | 否 | | 第三方事件码/节点码(保留原值) |
|
||||||
|
| event_text | TEXT | 否 | | 第三方事件文本(保留原值;展示前可做脱敏/清洗) |
|
||||||
|
| status_code | VARCHAR(32) | 否 | | 平台统一状态码(由 `event_code/event_text` 规则映射得出) |
|
||||||
|
| node_name | VARCHAR(128) | 是 | | 节点/网点名称(如“xx转运中心”) |
|
||||||
|
| location | TEXT | 是 | | 地理位置文本(如省市区/网点地址;不建议存精确经纬度,除非明确需求) |
|
||||||
|
| description | TEXT | 是 | | 平台补充说明(如“疑似退回件,已通知商家”) |
|
||||||
|
| evidence_urls | JSONB | 否 | 默认 `[]` | 证据/附件 URL 列表(签收图、面单、异常凭证等) |
|
||||||
|
| raw_payload | JSONB | 是 | | 第三方原始事件载荷(用于审计/排障;注意权限控制) |
|
||||||
|
| received_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 平台接收时间(用于排查延迟/乱序) |
|
||||||
|
| source | VARCHAR(16) | 否 | 默认 `'webhook'` | 数据进入平台的方式:`webhook`/`poll`/`manual` |
|
||||||
|
| dedupe_key | VARCHAR(256) | 否 | 参与唯一约束 | 幂等去重键(建议:有 `event_id` 用 `event_id`;否则构造 `tracking_no|event_code|event_time` 并按需要拼接 `node/location`) |
|
||||||
|
| created_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 入库时间 |
|
||||||
|
|
||||||
|
幂等与乱序建议:
|
||||||
|
- 幂等:插入事件时以 `(waybill_id, dedupe_key)` 唯一约束兜底;遇到冲突可 `ON CONFLICT DO NOTHING`。
|
||||||
|
- 乱序:允许旧事件晚到入库;前端展示按 `event_time` 排序。
|
||||||
|
- 运单摘要(`platform_express_waybills.current_status_*`)建议由“最新事件(按 event_time)映射结果”更新,避免用 `received_at` 作为最新依据。
|
||||||
|
|
||||||
|
|
||||||
|
### 3.3 `public.platform_express_event_raw`(原始接收留痕表)
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 记录每一次 webhook 回调或轮询结果的“原始内容 + 安全审计 + 解析结果”。
|
||||||
|
- 用于:验签问题定位、第三方字段变更回溯、解析失败重放、争议审计。
|
||||||
|
|
||||||
|
索引:
|
||||||
|
- `idx_platform_express_raw_received_at (received_at)`
|
||||||
|
- `idx_platform_express_raw_tracking_no (tracking_no)`
|
||||||
|
- `idx_platform_express_raw_signature_valid (signature_valid)`
|
||||||
|
- `gin_platform_express_raw_body`(`body` JSONB GIN,便于按字段排障检索)
|
||||||
|
|
||||||
|
字段表:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| id | UUID | 否 | PK,`uuid_generate_v4()` | 原始接收记录主键 |
|
||||||
|
| received_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 平台收到请求/响应的时间 |
|
||||||
|
| source | VARCHAR(16) | 否 | 默认 `'webhook'` | 来源:`webhook`/`poll`/`manual` |
|
||||||
|
| client_id | VARCHAR(64) | 是 | | 调用方/渠道标识(如回调应用 ID、聚合方商户号等) |
|
||||||
|
| carrier | VARCHAR(32) | 是 | | 承运方编码(可从路由/请求体解析;可能为空) |
|
||||||
|
| tracking_no | VARCHAR(64) | 是 | | 运单号(可能为空:如批量回调或解析失败) |
|
||||||
|
| signature_valid | BOOLEAN | 是 | | 验签是否通过(解析失败或未验签可为空) |
|
||||||
|
| signature | TEXT | 是 | | 签名原文(如 header 签名) |
|
||||||
|
| ts_header | TEXT | 是 | | 时间戳 header(用于防重放;字段名取决于对接规范) |
|
||||||
|
| request_id | VARCHAR(64) | 是 | | 请求追踪 ID(平台生成或对方传入) |
|
||||||
|
| remote_ip | INET | 是 | | 来源 IP(用于安全审计/黑白名单) |
|
||||||
|
| headers | JSONB | 是 | | 请求头(建议按需过滤敏感头再落库) |
|
||||||
|
| body | JSONB | 是 | | 原始请求体/响应体(用于回溯与重放) |
|
||||||
|
| parse_error | TEXT | 是 | | 解析错误信息(成功解析则为空) |
|
||||||
|
| dedupe_key | VARCHAR(256) | 是 | | 原始请求去重辅助键(可选:如 `hash(headers+body)`) |
|
||||||
|
|
||||||
|
权限与合规建议(实现侧):
|
||||||
|
- `raw_payload`、`platform_express_event_raw.body/headers` 可能包含敏感信息(手机号、地址、签名等),建议仅后台/运维具备读取权限,并记录审计日志。
|
||||||
|
|
||||||
|
## 4. 常见查询与写入路径(建议)
|
||||||
|
|
||||||
|
- 发货/绑定运单:
|
||||||
|
- Upsert `platform_express_waybills`(按 `(carrier, tracking_no)`);有订单关联时写入 `order_id/order_no`。
|
||||||
|
- 接收回调/轮询:
|
||||||
|
- 先插入 `platform_express_event_raw` 做留痕(无论解析成功与否)。
|
||||||
|
- 解析出事件后写入 `platform_express_tracking_events`;用 `dedupe_key` 做幂等。
|
||||||
|
- 视需要更新 `platform_express_waybills.current_status_*`、`last_synced_at`。
|
||||||
|
- 订单详情页展示:
|
||||||
|
- 通过 `order_id` 查 `platform_express_waybills`。
|
||||||
|
- 再按 `waybill_id` 查 `platform_express_tracking_events`,按 `event_time` 升序组装时间线。
|
||||||
82
pages/mall/delivery/doc/需求文档(现用)/缺口与待补充清单.md
Normal file
82
pages/mall/delivery/doc/需求文档(现用)/缺口与待补充清单.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 缺口与待补充清单(配送模块|第三方轨迹对接 + Mock 联调)
|
||||||
|
|
||||||
|
本文定位:
|
||||||
|
- 用“交付物清单”的方式回答:当前这套配送对接文档还缺什么、为什么缺、补了带来什么收益。
|
||||||
|
- 不改变现有方案(统一事件模型 + Adapter + 幂等乱序入库 + 前端统一契约),只补齐落地时最容易卡住的材料。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `README.md`:阅读入口与联调路径
|
||||||
|
- `配送模块需求文档.md`:目标/范围/验收口径
|
||||||
|
- `接口规范.md`:Webhook + 控制面 API + 入库建议
|
||||||
|
- `状态映射表.md`:event_code/event_text -> status_code + 一致性治理(含备选方案)
|
||||||
|
- `前端字段清单.md`:安卓/Web 展示契约
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0(建议本期补齐,影响联调与验收能否顺利推进)
|
||||||
|
|
||||||
|
1) 联调/验收 Checklist(平台侧 + Mock 侧)
|
||||||
|
- 为什么要补:现在“验收标准”是目标描述,但缺少可勾选的操作性检查项,QA/对接会反复口头对齐。
|
||||||
|
- 补齐收益:把“验签/幂等/乱序/重复/延迟/缺字段”等要求落到可执行项,减少漏测。
|
||||||
|
- 建议产物:一份表格/列表(可以后续直接贴到测试用例/测试报告里)。
|
||||||
|
|
||||||
|
2) 场景库清单(scenario catalog)+ 每个场景的事件序列期望
|
||||||
|
- 为什么要补:当前只提了 `standard_delivered` 等示例名,但没有定义“这个场景到底要生成哪些关键节点、每个节点对应哪些 event_code/status_code、时间间隔怎样”。
|
||||||
|
- 补齐收益:前端可稳定对照 UI;后端可稳定断言幂等与排序;QA 可复现回归。
|
||||||
|
- 建议产物:
|
||||||
|
- 场景列表(标准签收/拒收退回/地址异常/破损/丢失/长时间无更新/签收失败后成功等)
|
||||||
|
- 每个场景:事件数、关键事件码、是否包含 POD、是否包含异常、是否需要乱序/重复/延迟注入。
|
||||||
|
|
||||||
|
3) Webhook 接收契约的“ACK/重试”约定(平台与第三方的边界)
|
||||||
|
- 为什么要补:目前只写“成功返回 200”,但缺少“平台什么时候返回 2xx、什么时候 4xx/5xx、第三方是否重试、重试间隔与上限”的共识。
|
||||||
|
- 补齐收益:避免线上/联调出现“平台处理慢导致第三方重推爆量”“平台 4xx 被第三方当作可重试”之类问题。
|
||||||
|
- 建议产物:在 `接口规范.md` 增补一小节:
|
||||||
|
- 验签失败/缺字段/无法解析 -> 4xx(不建议重试)
|
||||||
|
- 平台内部暂时不可用 -> 5xx(允许重试)
|
||||||
|
- 成功接收但异步入库失败 -> 2xx 还是 5xx 的取舍(建议:接收成功就 2xx,入库失败走内部告警与补偿队列)。
|
||||||
|
|
||||||
|
4) 承运方接入配置表模板(Carrier Integration Profile)
|
||||||
|
- 为什么要补:你已经发现韵达/圆通等第三方细节差异大,缺少一个“把关键信息落盘”的标准表格,后续每接一家都要重新摸索。
|
||||||
|
- 补齐收益:缩短接新承运方周期;把差异收敛到 Adapter/配置层;减少口头信息丢失。
|
||||||
|
- 建议字段(模板):
|
||||||
|
- `carrier`、接入模式(webhook/polling/both)
|
||||||
|
- 鉴权类型与签名算法、参与签名字段、是否含 timestamp/nonce
|
||||||
|
- 轨迹查询接口(URL/method/v/字段示例)、回调接口(回调验签字段/重试策略)
|
||||||
|
- 是否提供稳定 `event_id`、是否要求手机号后四位
|
||||||
|
- 时间格式/时区、地点粒度
|
||||||
|
- 状态码枚举来源与映射版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1(建议尽快补齐,影响长期维护与一致性/审计能力)
|
||||||
|
|
||||||
|
1) 权限矩阵与审计要求(谁能看 raw_payload / POD / 全量地址手机号)
|
||||||
|
- 原因:`raw_payload` 可包含敏感字段;Web 客服/运维能力需要最小权限与审计。
|
||||||
|
- 产物:角色(普通用户/商家/客服/运维/管理员)× 字段(raw/POD/手机号/地址)的可见性矩阵 + 审计日志要求。
|
||||||
|
|
||||||
|
2) 错误码与返回体统一(控制面 API)
|
||||||
|
- 原因:目前 `接口规范.md` 仅列举了示例错误码,但缺少标准响应结构。
|
||||||
|
- 产物:统一响应:`{success, code, message, request_id, data}`;并约定 HTTP 状态码与业务码的对应。
|
||||||
|
|
||||||
|
3) OpenAPI(可选)
|
||||||
|
- 原因:接口多了以后靠 Markdown 容易漂移。
|
||||||
|
- 产物:控制面 API + Webhook payload 的 OpenAPI(至少给 Mock 侧)。
|
||||||
|
|
||||||
|
4) “状态不回退/一致性”策略的明确算法(平台侧)
|
||||||
|
- 原因:`接口规范.md` 提了可选规则,但未选型;当出现终态后补到更早事件时,当前状态如何计算需要一口径。
|
||||||
|
- 产物:选定一种算法并写清:以 `event_time` 最新为准 / 状态等级只进不退 / 双轨(展示按时间,current_status 不回退)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2(可后补,锦上添花)
|
||||||
|
- DB 字典与 ER 图(便于新人理解 schema)
|
||||||
|
- 场景导入导出(JSON)格式规范
|
||||||
|
- 对接灰度/熔断/降级策略的配置说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建议补充顺序(最省时间)
|
||||||
|
1) 先补 P0-1(Checklist)+ P0-2(场景库清单)——直接提升联调效率
|
||||||
|
2) 再补 P0-3(ACK/重试约定)——避免重复推送与误判
|
||||||
|
3) 再补 P0-4(承运方接入配置表模板)——为后续接真实承运方铺路
|
||||||
|
4) 最后补 P1 项——为线上长期治理做准备
|
||||||
317
pages/mall/delivery/doc/需求文档(现用)/配送模块需求文档.md
Normal file
317
pages/mall/delivery/doc/需求文档(现用)/配送模块需求文档.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# 配送模块需求文档(模拟三通一达后台 / Mock 承运方 Server)
|
||||||
|
|
||||||
|
本文定位:定义 Mock 承运方 Server 的目标、范围、流程、故障注入与验收标准;接口与字段细节以 `接口规范.md`、`前端字段清单.md` 为准。
|
||||||
|
|
||||||
|
口径说明:本目录文档作为联调、验收与实现的唯一口径;如与口头沟通或临时讨论结论不一致,以本文档及其关联文档(接口/字段/映射)为准,并通过文档更新同步。
|
||||||
|
|
||||||
|
关联文档:
|
||||||
|
- `README.md`:阅读入口与联调路径
|
||||||
|
- `接口规范.md`:Webhook 推送规范 + Mock 控制面 API
|
||||||
|
- `状态映射表.md`:event_code/event_text -> 平台 status_code
|
||||||
|
- `前端字段清单.md`:安卓/Web 时间线字段契约
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
当前配送链路依赖第三方承运方(常见为“三通一达”等快递公司或聚合平台)。在真实承运方接口尚未签约/联调完成前,需要一个**可控的模拟承运方后台服务(Mock Server)**,用于开发与测试平台侧的:Webhook 接收、验签、幂等入库、时间线展示、异常处理与监控告警。
|
||||||
|
|
||||||
|
系统定位与边界:
|
||||||
|
- 本模块属于平台系统的一部分,最终呈现在“订单详情页”的物流区块,并同时服务客服/履约/对接排障。
|
||||||
|
- 平台端需要查看订单的履约详情(至少包括:`order_no`、`tracking_no`、`carrier`、物流时间线 `status_history`、异常与处理记录)。
|
||||||
|
- 但本需求不覆盖商品明细、支付、营销等订单业务域的展示与流程;这里聚焦“物流轨迹对接、入库与展示”。
|
||||||
|
|
||||||
|
多端归属与页面边界(统一口径):
|
||||||
|
- 本需求产出的“物流能力”不是一个独立的骑手/配送员端 App,而是作为同一套物流模块能力,分别嵌入到三个端(按权限展示不同颗粒度):
|
||||||
|
1) 消费者端(C 端):订单详情的物流时间线/物流详情(只读为主,展示 `status_code` + 轨迹文案;可在“派送中”等末端环节提供“联系配送”能力,但涉及手机号等敏感信息需脱敏/虚拟号策略,且仅对收件人可见;不展示 `raw_payload`)。
|
||||||
|
2) 商家端(B 端):发货与运单绑定(选择承运方、录入/回填 `tracking_no`)+ 查看物流概览(以“到达某地/中转/派送/签收/异常”等大致进度为主;不展示配送员手机号等个人敏感信息;不包含对接配置与敏感调试信息)。
|
||||||
|
3) 管理端(平台后台:客服/履约/对接运维):订单/运单查询、Webhook 接收日志、接入配置、(可选)监控告警与审计能力。
|
||||||
|
- 三端展示字段口径以统一事件模型与前端字段契约为准(见 `接口规范.md`、`前端字段清单.md`),避免出现“不同端展示不一致/状态口径不一致”。
|
||||||
|
- 若未来要做“自营配送/同城骑手端”(接单、导航、到店取货、送达签收等),需单独立项与另写需求文档,不在本需求范围内。
|
||||||
|
|
||||||
|
敏感信息展示差异(必须遵循):
|
||||||
|
- 轨迹事件以同一份“事实数据源”入库与治理(`event_code/event_text/status_code` 等同源),但不同端的展示需按角色过滤与脱敏。
|
||||||
|
- 配送员/快递员手机号:
|
||||||
|
- 商家端:不展示。
|
||||||
|
- 消费者端:仅在末端环节(如 `OUT_FOR_DELIVERY`)按需提供,并必须脱敏(如 138****8000)或采用虚拟号/中转号方案;避免把第三方 `event_text` 原文中的手机号明文直接透传到推送消息或页面。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
- 适用阶段:主要用于真实第三方承运方未签约/未联调/不可控时的替代数据源;即便后续已接入真实承运方,也保留用于回归测试、故障注入与排障复现(生产环境默认关闭)。
|
||||||
|
- 提供一个可配置的 Mock 承运方服务,模拟多承运方(圆通/韵达/中通/申通)轨迹事件与签收凭证。
|
||||||
|
- 支持将轨迹事件按既定规范推送到平台 Webhook(模拟承运方 -> 平台)。
|
||||||
|
- 支持主动查询轨迹(平台轮询场景)与“预置场景脚本”快速生成整条物流生命周期。
|
||||||
|
- 支持故障注入:延迟、重复推送、乱序、签名错误、时间戳偏移、缺字段等,用于验证平台鲁棒性。
|
||||||
|
|
||||||
|
## 2.1 配送合作模式(不同解决方案)
|
||||||
|
|
||||||
|
说明:以下方案是“商城履约/配送合作关系”的产品与工程取舍;无论选哪一种,只要前端要展示稳定一致的物流时间线,平台都建议建设统一的轨迹模型与查询接口(见 `接口规范.md` 与数据库 Schema)。本 Mock 体系用于在第三方未就绪或不可控时,替代外部系统快速联调与做故障注入回归。
|
||||||
|
|
||||||
|
### 方案 A:平台统一配送(平台与第三方合作 / 统采统接)
|
||||||
|
适用:希望提供强一致的履约体验、统一客服口径、统一监控与统计;平台愿意承担对接与运维成本。
|
||||||
|
|
||||||
|
平台职责:
|
||||||
|
- 统一签约/选择承运方或聚合平台;提供平台侧发货能力(下单、订阅/回调、轨迹查询与回单能力按合同)。
|
||||||
|
- 平台统一事件模型入库(幂等去重、乱序处理、状态不回退),对客户端输出统一 `status_history`。
|
||||||
|
|
||||||
|
商家职责:
|
||||||
|
- 在平台内选择配送方案并发货;不需要自行对接承运方轨迹接口(或仅提供必要发货信息)。
|
||||||
|
|
||||||
|
优点:体验一致,平台可观测性强;扩展新承运方主要改 Adapter,不影响前端契约。
|
||||||
|
风险/成本:平台对接与运维成本高;履约兜底责任更集中在平台侧。
|
||||||
|
|
||||||
|
与 Mock 的关系:
|
||||||
|
- Mock 用于第三方未联通阶段的替代数据源与故障注入;生产环境默认关闭。
|
||||||
|
|
||||||
|
### 方案 B:商家自选配送(商家自选承运商并发货 / 平台负责运单绑定与轨迹展示)
|
||||||
|
适用:平台招商、品类/区域差异大、平台希望给商家“选择承运方”的灵活性;平台不承担“实际配送执行”,但需要提供统一的运单绑定入口与轨迹展示底座。
|
||||||
|
|
||||||
|
重要说明(商家只是平台商户的常见情况):
|
||||||
|
- 默认不要求商家自建系统或自行对接第三方 API。
|
||||||
|
- “商家自选”指商家在平台提供的承运方/聚合平台范围内进行选择,第三方对接、验签、幂等、入库与前端展示由平台统一承担。
|
||||||
|
|
||||||
|
平台职责(建议至少做到“统一展示底座”):
|
||||||
|
- 提供统一轨迹查询接口给前端;平台内部仍建议使用统一事件模型入库。
|
||||||
|
- 平台负责与第三方承运方/聚合平台对接:订阅/回调(Webhook)或轨迹查询(轮询补偿)、验签、防重放、幂等去重、乱序处理与统一入库。
|
||||||
|
- 平台提供“发货与运单绑定”的入口(承运方选择、运单号录入/回填/打单),把差异收敛在平台 Adapter/映射规则,避免前端直接适配多家第三方。
|
||||||
|
|
||||||
|
商家职责:
|
||||||
|
- 商家在平台的订单列表/订单详情页点击“发货/绑定运单”,选择承运方并录入/回填 `tracking_no`;`order_no` 由平台订单上下文自动带入,不要求商家手工输入订单号。
|
||||||
|
- `ORDER_PLACED`(已下单)阶段允许 `tracking_no` 为空(前端展示“暂无运单号”);完成发货绑定后进入 `SHIPPED`,此时必须具备 `carrier + tracking_no`。
|
||||||
|
- 若商家坚持使用“商家与第三方的独立合同/账号”,可向平台提交第三方对接所需材料,由平台统一配置与代接入(不要求商家自建回调服务)。
|
||||||
|
|
||||||
|
可选增强:商家在平台内“自助对接第三方”(无需商家开发)
|
||||||
|
- 定位:可选增强能力,非一期必做。
|
||||||
|
- 目标:让商家在平台后台完成第三方账号授权/配置,从而在平台内完成下单打单、获取运单号、查询/订阅轨迹;平台仍统一入库与展示。
|
||||||
|
- 常见两种形态(可二选一,也可并存):
|
||||||
|
1) 聚合平台授权开通(推荐优先):平台统一对接一家聚合服务商;商家在平台内完成开通/授权后即可使用多家快递能力。
|
||||||
|
2) 商家自带第三方账号(BYO Account):商家已与某快递/聚合平台签约;在平台后台录入密钥或走 OAuth 授权,平台代为调用第三方 API。
|
||||||
|
- 商家后台最小页面/流程建议:
|
||||||
|
- 【物流渠道管理】:选择渠道类型(聚合/直连)、开启/停用
|
||||||
|
- 【授权与密钥】:录入/更新 `appKey/appSecret/token` 或 OAuth 绑定;展示“最后一次连通性检测”结果
|
||||||
|
- 【回调配置提示】:展示平台 Webhook 地址与白名单要求(若第三方需要配置回调)
|
||||||
|
- 【测试与排障】:一键“连通性测试/拉取一条轨迹/模拟下单”,失败给出可读错误
|
||||||
|
- 安全要求(必须):
|
||||||
|
- 第三方密钥加密存储、最小权限、变更审计;仅商家管理员可配置。
|
||||||
|
- 不在前端暴露密钥;平台服务端代调用第三方接口。
|
||||||
|
- 轨迹/运单数据仍写入平台统一表结构与事件模型,避免不同渠道把差异带到前端。
|
||||||
|
|
||||||
|
是否需要做(决策建议):
|
||||||
|
- 暂不需要(建议先不做)的场景:一期目标仅是“商家回填运单号 + 平台展示第三方轨迹关键节点”,且商家规模不大/对接资源有限。
|
||||||
|
- 建议需要(做了收益明显)的场景:商家量大且运单号回填错误率高、客服投诉“查不到物流/不更新”多;或明确要上“电子面单/平台一键下单”。
|
||||||
|
- 推荐分期:
|
||||||
|
- Phase 1:回填运单号 + 轨迹接入与统一展示底座(本项目当前优先级)。
|
||||||
|
- Phase 2:先接入 1 家聚合平台做统一下单/面单/轨迹(降低多家直连成本)。
|
||||||
|
- Phase 3:再开放 BYO Account(商家自带账号)自助配置(安全与运维成本最高)。
|
||||||
|
|
||||||
|
优点:相对方案 A 平台责任更轻、商家更灵活。
|
||||||
|
风险/成本:体验碎片化风险大;如果不强制回传规范,平台客服/前端会被迫处理多样差异,长期维护成本更高。
|
||||||
|
|
||||||
|
#### 商家自选配送需要提供的内容(接入清单)
|
||||||
|
|
||||||
|
基础信息(发货侧最小闭环):
|
||||||
|
- `carrier`:承运方标识(可为直连承运方或聚合平台,如 YUNDA/YTO/ZTO/STO/KDN 等)。
|
||||||
|
- `tracking_no`:运单号生成与回传方式(商家生成/第三方返回/平台生成)。
|
||||||
|
- 订单关联信息:`order_no`(或平台侧可解析的业务单号),用于把轨迹绑定到订单详情页;通常由平台在“订单发货/绑定运单”操作中自动带入,不要求商家手工录入。
|
||||||
|
|
||||||
|
运单号(`tracking_no`)获取方式(两种常见落地,二选一或并存):
|
||||||
|
1) 回填运单号(最小模式,推荐先上线):
|
||||||
|
- 商家在线下/快递官方系统/聚合平台完成下单与交接,获得运单号。
|
||||||
|
- 商家在平台后台“发货/绑定运单”时选择承运商并回填 `tracking_no`(订单号无需手填,平台自动关联当前订单)。
|
||||||
|
- 平台不需要调用第三方“下单/面单”接口,只需后续接入轨迹(Webhook/轮询)用于展示。
|
||||||
|
2) 电子面单 / 在线下单(增强模式,体验更好):
|
||||||
|
- 平台(或平台集成的服务商)需要对接第三方提供的“下单/面单”接口,向第三方提交发货所需信息(收件人/地址/重量/件数等),并获得:运单号 `tracking_no` + 面单文件/面单号等。
|
||||||
|
- 第三方可以是:
|
||||||
|
- 直连快递公司接口(每家快递一套协议);或
|
||||||
|
- 快递聚合平台接口(一套协议覆盖多家快递)。
|
||||||
|
- 兜底策略建议:若第三方下单失败/超时,允许商家改为“手工回填运单号”完成发货闭环。
|
||||||
|
|
||||||
|
轨迹数据接入方式(平台统一接入,推荐第一种):
|
||||||
|
1) 第三方 -> 平台 Webhook 推送(推荐):
|
||||||
|
- 平台与第三方完成订阅/回调配置;第三方直接回调平台 Webhook。
|
||||||
|
- 平台统一完成:验签、防重放、字段映射、状态映射、幂等去重、乱序入库。
|
||||||
|
2) 平台 -> 第三方 轮询拉取(可选补偿):
|
||||||
|
- 平台按承运方策略轮询“长时间无更新”的运单,补齐轨迹。
|
||||||
|
- 适用于第三方不稳定、回调丢失或仅提供查询能力的场景,但平台运维成本更高。
|
||||||
|
|
||||||
|
(高级模式,可选,适用于有自建系统的大商家)
|
||||||
|
- 商家系统 -> 平台:商家按平台统一事件模型回传轨迹事件;平台仍执行幂等/乱序入库与审计。
|
||||||
|
|
||||||
|
对接材料(按商家选择的接入方式提供):
|
||||||
|
- 若商家仅在“平台已接入承运方范围内自选”,通常无需商家提供第三方接口材料。
|
||||||
|
- 若商家使用“独立合同/账号”并要求平台代接入,则需要商家提供或协助申请:
|
||||||
|
- 鉴权信息:`appKey/appSecret`、`token`、证书、公网 IP 白名单等(因第三方而异)。
|
||||||
|
- 回调能力:是否支持订阅 + Webhook(以及重试策略、签名算法、回调白名单)。
|
||||||
|
- 能力说明:是否提供 `event_id`、是否提供 POD/签收凭证、是否需要手机号后四位参与查询等。
|
||||||
|
|
||||||
|
映射配置(用于消除差异化,平台侧必须落库为配置或代码映射):
|
||||||
|
- 事件码/文案 -> 平台 `status_code` 的映射(至少覆盖:揽收/在途/派送中/签收/异常/退回)。
|
||||||
|
- 幂等去重策略:优先 `event_id`;缺失时使用组合键(见 `接口规范.md` 的入库层建议)。
|
||||||
|
|
||||||
|
#### 平台统一配送状态(`status_code`,必须遵循)
|
||||||
|
|
||||||
|
说明:平台内部与对外输出统一使用同一套 `status_code`,三端(C/B/管理端)按需展示,不得随意改写状态口径。
|
||||||
|
|
||||||
|
状态枚举(当前统一口径):
|
||||||
|
- `ORDER_PLACED`:已下单(平台侧订单已创建;通常不是第三方快递事件;此阶段允许 `tracking_no` 为空)
|
||||||
|
- `SHIPPED`:已发货(已绑定运单/待揽收)
|
||||||
|
- `IN_TRANSIT`:运输中(所有中转/分拨/到达节点统一归类为运输中)
|
||||||
|
- `OUT_FOR_DELIVERY`:派送中
|
||||||
|
- `READY_FOR_PICKUP`:待取件(驿站/自提柜等)
|
||||||
|
- `DELIVERED`:已签收
|
||||||
|
- `EXCEPTION`:异常
|
||||||
|
- `RETURNED`:退回/退件
|
||||||
|
|
||||||
|
展示规范:
|
||||||
|
- 为保证用户体验,应真实展示第三方回传的物流轨迹文案(如“到达北京分拨中心”、“离开上海网点”等),不做脱敏处理。
|
||||||
|
- 前端时间线展示 `event_text` 原文,状态标签对应 `status_code`。
|
||||||
|
|
||||||
|
说明:本项目不维护“发货前”的配送状态;在未绑定运单阶段属于订单域状态,不纳入物流轨迹状态枚举。
|
||||||
|
|
||||||
|
补充约束(与页面展示相关):
|
||||||
|
- 当 `tracking_no` 为空时,前端仅展示订单的配送状态为 `ORDER_PLACED` 与“暂无运单号”,不展示可查询的第三方物流时间线;待商家完成“发货/绑定运单”后,再展示轨迹时间线。
|
||||||
|
|
||||||
|
#### 差异化来源与平台收敛策略
|
||||||
|
差异化来源(商家自选必然存在):
|
||||||
|
- 鉴权差异:Token/HMAC/证书/IP 白名单等。
|
||||||
|
- 字段差异:时间格式、地点粒度、是否有 `event_id`、异常字段命名等。
|
||||||
|
- 状态差异:同一状态不同叫法/码值,甚至“派送/签收/退回”的边界不同。
|
||||||
|
- 交互差异:仅查询(轮询)/订阅回调(Webhook)/两者并存;重试与乱序程度不同。
|
||||||
|
|
||||||
|
平台收敛策略(避免差异扩散到前端与数据库):
|
||||||
|
- 前端只消费平台统一查询接口与统一字段(见 `前端字段清单.md`),不得直接依赖第三方字段。
|
||||||
|
- 平台统一入库模型与幂等/乱序规则(见 `接口规范.md`),第三方差异仅在 Adapter/配置映射层处理。
|
||||||
|
- 分级能力与降级:
|
||||||
|
- 达标(可回传轨迹或可稳定拉取 + 通过验收)-> 展示完整时间线 + 可做告警统计。
|
||||||
|
- 不达标(仅能提供运单号或字段缺失严重)-> 降级为“仅展示运单号/跳转外部查询”,不承诺完整时间线。
|
||||||
|
|
||||||
|
验收建议(商家自配准入):
|
||||||
|
- 必测:重复推送、乱序、延迟、验签失败、缺字段(对应本 Mock 的故障注入项)。
|
||||||
|
- 通过后才允许全量展示/告警统计;未通过仅允许降级展示。
|
||||||
|
|
||||||
|
与 Mock 的关系:
|
||||||
|
- Mock 可作为“平台对接与回归”的验收工具:在第三方未就绪或不可控时,先用 Mock 场景验证幂等/乱序/缺字段容错等;接入真实第三方后也可用于故障注入回归。
|
||||||
|
|
||||||
|
### 方案 C:混合模式(平台默认合作 + 允许商家自配)【推荐】
|
||||||
|
适用:既要平台可控的默认体验,又要给大商家/特殊品类保留弹性。
|
||||||
|
|
||||||
|
规则建议:
|
||||||
|
- 默认:商家走平台合作承运方(覆盖大多数订单,保证体验一致)。
|
||||||
|
- 例外:允许商家自配,但必须满足平台回传规范(至少事件时间/文案/状态映射可得);否则只提供降级展示能力。
|
||||||
|
- 前端:永远只消费平台统一查询接口,不直接依赖第三方字段。
|
||||||
|
|
||||||
|
落地建议(最小闭环):
|
||||||
|
- 平台统一事件表与查询 API 先上线;Mock 先跑通联调与回归。
|
||||||
|
- Phase 1:接 1 家聚合或 1 家主力承运方(平台统一配送)。
|
||||||
|
- Phase 2:开放商家自配(按准入与验收清单接入)。
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
### 3.1 包含
|
||||||
|
- Mock Server 对外提供控制面 API:创建/查询运单、追加事件、运行场景、触发推送、配置回调目标。
|
||||||
|
- Mock Server 向平台推送 Webhook:按统一事件模型、支持 HMAC 验签头。
|
||||||
|
- 事件与场景数据本地持久化(最小可用可内存;建议支持文件落盘,便于复现)。
|
||||||
|
|
||||||
|
### 3.2 不包含
|
||||||
|
- 真实承运方业务能力(计费、真实网点、真实 POD 获取)
|
||||||
|
- 配送员实时定位与地图
|
||||||
|
- 平台侧业务实现(本需求仅定义 Mock Server 的接口与行为)
|
||||||
|
|
||||||
|
## 4. 目标用户
|
||||||
|
- 平台后端开发:联调 Webhook、验签、幂等与入库。
|
||||||
|
- 平台前端开发:使用固定运单/事件数据验证时间线展示。
|
||||||
|
- QA 测试:用故障注入覆盖异常链路与回归。
|
||||||
|
- 运维/对接:用于排查“回调未到/重复到/乱序到”等问题。
|
||||||
|
|
||||||
|
## 4.1 使用端(主要投放端)
|
||||||
|
- 安卓端(App):订单详情页展示物流时间线、签收凭证预览、异常上报。
|
||||||
|
- Web 端(H5/PC 管理后台或用户 Web):同样展示物流时间线,提供更完整的原始事件/审计信息(按权限)。
|
||||||
|
|
||||||
|
## 4.2 平台后台展示建议(客服/履约/对接运维最小集)
|
||||||
|
|
||||||
|
说明:本节定义“平台侧管理后台”在物流模块上建议具备的最小展示与排障能力(不等同于完整订单后台)。
|
||||||
|
|
||||||
|
1) 订单详情页(物流区块)
|
||||||
|
- 基础信息:`order_no`、`carrier`、`tracking_no`、当前 `status_code`、`last_synced_at`
|
||||||
|
- 物流时间线:`status_history`(按 `event_time` 排序,展示 `event_text`,标签使用 `status_code`)
|
||||||
|
- 异常提示:长时间无更新、签收失败、退回等(按 `status_code` 与规则触发)
|
||||||
|
|
||||||
|
2) 运单/轨迹查询页(排障入口)
|
||||||
|
- 支持按 `tracking_no/order_no/carrier/status_code` 查询
|
||||||
|
- 展示最近 N 条事件与来源(mock/承运方/聚合),便于快速定位
|
||||||
|
|
||||||
|
3) 对接日志与审计(运维/对接用)
|
||||||
|
- Webhook 接收日志:验签结果、去重命中、入库结果/错误原因(按权限)
|
||||||
|
- 原始回文:`raw_payload` 折叠展示(仅客服/运维可见,记录访问审计)
|
||||||
|
|
||||||
|
4) 配置中心(对接运维用)
|
||||||
|
- 承运方/聚合平台配置、Webhook 目标与密钥/证书、轮询补偿开关与频率
|
||||||
|
- 测试环境 Mock 开关(默认关闭,需显式开启)
|
||||||
|
|
||||||
|
(可选增强)一致性回查/纠偏入口:按需触发第三方回查,补采缺失事件并保留审计记录。
|
||||||
|
|
||||||
|
## 4.3 商家后台展示建议(商户侧最小集)
|
||||||
|
|
||||||
|
说明:商家只是平台商户时,建议仅提供“发货与运单绑定 + 物流查看”的最小能力。
|
||||||
|
|
||||||
|
- 发货:选择承运方、录入/回填 `tracking_no`、发货确认
|
||||||
|
- 订单详情(物流区块):查看时间线与当前状态(不展示 `raw_payload`)
|
||||||
|
|
||||||
|
端侧差异化要求:
|
||||||
|
- 安卓端需支持弱网/后台切换恢复后的快速刷新;证据图片需支持点击预览/下载。
|
||||||
|
- Web 端需支持表格/筛选/搜索(便于客服排障);对调试信息(raw_payload)默认折叠。
|
||||||
|
|
||||||
|
## 5. 核心概念
|
||||||
|
- 承运方(carrier):YUNDA / YTO / ZTO / STO(可扩展)
|
||||||
|
- 运单(tracking_no):可由 Mock Server 生成或由调用方指定
|
||||||
|
- 事件(event):包含 event_id、event_time、event_code、event_text、node_name、location、evidence_urls 等
|
||||||
|
- 场景(scenario):一组按时间顺序生成的事件脚本(如“标准签收”“拒收退回”“地址异常”)
|
||||||
|
|
||||||
|
## 6. 关键流程
|
||||||
|
1) 配置推送目标:设置平台 Webhook URL、`client_id`、`secret`。
|
||||||
|
2) 创建运单:选择 carrier,生成/指定 tracking_no。
|
||||||
|
3) 运行场景:Mock Server 生成一系列关键节点事件并入库。
|
||||||
|
4) 触发推送:Mock Server 按顺序(或按乱序/延迟策略)将事件推送到平台。
|
||||||
|
5) 平台侧验证:校验验签、去重、入库、前端展示与告警。
|
||||||
|
|
||||||
|
## 7. 功能需求
|
||||||
|
### 7.1 控制面 API(Mock Server)
|
||||||
|
- 配置:设置 webhook 目标、密钥、默认承运方。
|
||||||
|
- 运单:创建/查询/列表。
|
||||||
|
- 事件:追加/查询/清空;支持批量追加。
|
||||||
|
- 场景:运行预置脚本(标准流程/异常流程)。
|
||||||
|
- 推送:立即推送指定运单的全部/增量事件;支持模拟重试与重复。
|
||||||
|
|
||||||
|
### 7.2 Webhook 推送(Mock -> 平台)
|
||||||
|
- 请求头包含 `X-Client-Id`、`X-Timestamp`、`X-Signature`。
|
||||||
|
- body 结构遵循统一事件模型(见 `接口规范.md`)。
|
||||||
|
- 幂等:对同一事件可重复推送,平台需以 `event_id` 去重。
|
||||||
|
|
||||||
|
### 7.3 故障注入(必须)
|
||||||
|
- `delay_ms`:推送延迟
|
||||||
|
- `duplicate`:重复推送次数
|
||||||
|
- `out_of_order`:乱序推送
|
||||||
|
- `bad_signature`:签名错误
|
||||||
|
- `timestamp_skew_seconds`:时间戳偏移
|
||||||
|
- `drop_fields`:缺字段(用于校验平台必填校验与容错)
|
||||||
|
|
||||||
|
## 8. 非功能需求
|
||||||
|
- 易用性:一条命令启动;提供最少的配置即可发送事件。
|
||||||
|
- 可复现:场景运行应可导出/导入(或提供固定随机种子)。
|
||||||
|
- 可观测:每次推送记录 request/response 摘要与 request_id。
|
||||||
|
- 安全:仅用于内网/测试环境;支持简单 Token 或 IP 白名单(可选)。
|
||||||
|
|
||||||
|
端侧体验要求(安卓/Web):
|
||||||
|
- 刷新策略:页面进入时拉取一次平台侧轨迹;用户可手动“刷新物流”;显示 `last_synced_at`。
|
||||||
|
- 离线降级:无网络时展示上次缓存的时间线并提示“网络不可用”。
|
||||||
|
- 权限与隐私:手机号脱敏;配送员/快递员手机号仅对收件人可见且需脱敏/虚拟号策略(商家端不展示);`raw_payload` 仅客服/运维可见并记录审计;不展示配送员精确地址/定位。
|
||||||
|
|
||||||
|
## 9. 验收标准
|
||||||
|
- 能创建运单并运行“标准签收”场景,向平台推送至少 6 个关键节点(揽收/到达中转/在途/到达目的地/派送中/签收)。
|
||||||
|
- 能注入重复与乱序事件,平台侧仍能正确去重并按时间线展示。
|
||||||
|
- 能注入验签失败事件,平台侧能拒绝并记录告警。
|
||||||
|
|
||||||
|
## 10. 文档与交付物
|
||||||
|
- `接口规范.md`:Mock Server 控制面 API + Webhook 推送规范
|
||||||
|
- `状态映射表.md`:事件码/原文到平台统一状态映射建议
|
||||||
|
- `前端字段清单.md`:前端展示字段契约(供时间线组件使用)
|
||||||
|
- 数据库 Schema:`mall_sql/schemas/express_tracking_mock_platform.sql`(平台统一轨迹入库 + Mock 承运方持久化表)
|
||||||
|
|
||||||
|
## 11. 待补充项(备忘与下一步)
|
||||||
|
为保证联调、验收与后续接真实承运方不走弯路,建议按优先级补齐:
|
||||||
|
- `缺口与待补充清单.md`
|
||||||
@@ -97,8 +97,6 @@
|
|||||||
<text class="section-title">附近订单</text>
|
<text class="section-title">附近订单</text>
|
||||||
<view class="section-header-actions">
|
<view class="section-header-actions">
|
||||||
<text class="refresh-btn" @click="refreshOrders">🔄 刷新</text>
|
<text class="refresh-btn" @click="refreshOrders">🔄 刷新</text>
|
||||||
<!-- 当可接取订单达到上限时显示更多入口 -->
|
|
||||||
<text v-if="availableOrders && availableOrders.length >= 20" class="more-btn" @click="goToAllOrders">更多 ➜</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -107,34 +105,41 @@
|
|||||||
<text class="empty-subtitle">请保持在线状态,有新订单会自动推送</text>
|
<text class="empty-subtitle">请保持在线状态,有新订单会自动推送</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-for="order in availableOrders" :key="order.id" class="order-card">
|
<view v-for="(order, index) in availableOrders" :key="order.id">
|
||||||
<view class="order-header">
|
<view v-if="index < 5" class="order-card">
|
||||||
<text class="order-id">{{ order.order_no }}</text>
|
<view class="order-header">
|
||||||
<text class="order-fee">¥{{ order.delivery_fee }}</text>
|
<text class="order-id">{{ order.order_no }}</text>
|
||||||
</view>
|
<text class="order-fee">¥{{ order.delivery_fee }}</text>
|
||||||
|
|
||||||
<view class="order-route">
|
|
||||||
<view class="route-item">
|
|
||||||
<text class="route-icon">📍</text>
|
|
||||||
<text class="route-text">{{ order.pickup_address.area }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="route-arrow">→</text>
|
|
||||||
<view class="route-item">
|
<view class="order-route">
|
||||||
<text class="route-icon">🏠</text>
|
<view class="route-item">
|
||||||
<text class="route-text">{{ order.delivery_address.area }}</text>
|
<text class="route-icon">📍</text>
|
||||||
|
<text class="route-text">{{ order.pickup_address.area || order.pickup_address.detail }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="route-arrow">→</text>
|
||||||
|
<view class="route-item">
|
||||||
|
<text class="route-icon">🏠</text>
|
||||||
|
<text class="route-text">{{ order.delivery_address.area || order.delivery_address.detail }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-info">
|
||||||
|
<text class="info-item">距离: {{ order.distance }}km</text>
|
||||||
|
<text class="info-item">预计: {{ order.estimated_time }}分钟</text>
|
||||||
|
<text class="info-item">下单: {{ formatTime(order.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-actions">
|
||||||
|
<button class="order-btn accept" @click="acceptOrder(order.id)">接受订单</button>
|
||||||
|
<button class="order-btn detail" @click="viewOrderDetail(order.id)">查看详情</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<view class="order-info">
|
|
||||||
<text class="info-item">距离: {{ order.distance }}km</text>
|
<!-- 超过5个展示订单就有个加载更多 -->
|
||||||
<text class="info-item">预计: {{ order.estimated_time }}分钟</text>
|
<view v-if="availableOrders.length > 5" class="view-all-footer" @click="goToAllOrders">
|
||||||
<text class="info-item">下单: {{ formatTime(order.created_at) }}</text>
|
<text class="view-all-text">查看全部订单 (共 {{ availableOrders.length }} 个待接订单) ➜</text>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="order-actions">
|
|
||||||
<button class="order-btn accept" @click="acceptOrder(order.id)">接受订单</button>
|
|
||||||
<button class="order-btn detail" @click="viewOrderDetail(order.id)">查看详情</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -308,7 +313,10 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (res && (res.data instanceof Array) && res.data.length > 0) {
|
if (res && (res.data instanceof Array) && res.data.length > 0) {
|
||||||
this.driverInfo = Object.assign(this.driverInfo, res.data[0])
|
const data = res.data[0] as DeliveryDriverType
|
||||||
|
this.driverInfo = Object.assign(this.driverInfo, data)
|
||||||
|
// 同步工作状态到本地变量
|
||||||
|
this.isOnline = (this.driverInfo.work_status === 1)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('loadDriverInfo error', e)
|
console.error('loadDriverInfo error', e)
|
||||||
@@ -397,6 +405,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
console.log('loadAvailableOrders: query result=', res)
|
console.log('loadAvailableOrders: query result=', res)
|
||||||
if (res && Array.isArray(res.data)) {
|
if (res && Array.isArray(res.data)) {
|
||||||
const fetched = (res.data as Array<any>).map((r:any) => this._transformTask(r))
|
const fetched = (res.data as Array<any>).map((r:any) => this._transformTask(r))
|
||||||
|
console.log('loadAvailableOrders: transformed count=', fetched.length)
|
||||||
// 再次检查 currentTask,避免并发情况下短暂展示可接单
|
// 再次检查 currentTask,避免并发情况下短暂展示可接单
|
||||||
if (this.currentTask) {
|
if (this.currentTask) {
|
||||||
this.availableOrders = []
|
this.availableOrders = []
|
||||||
@@ -434,6 +443,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
order_id: task.order_id || task.orderId || task.orderId || '',
|
||||||
order_no: task.order_no || task.orderNo || task.trade_no || '',
|
order_no: task.order_no || task.orderNo || task.trade_no || '',
|
||||||
status: Number(task.status) || 1,
|
status: Number(task.status) || 1,
|
||||||
pickup_address: parseAddress(task.pickup_address),
|
pickup_address: parseAddress(task.pickup_address),
|
||||||
@@ -465,8 +475,34 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
|
|
||||||
// 切换工作状态
|
// 切换工作状态
|
||||||
toggleWorkStatus(event: UniSwitchChangeEvent) {
|
toggleWorkStatus(event: UniSwitchChangeEvent) {
|
||||||
this.isOnline = event.detail.value
|
const targetStatus = event.detail.value
|
||||||
|
|
||||||
|
// 检查是否有当前任务,不允许离线
|
||||||
|
if (!targetStatus && this.currentTask != null) {
|
||||||
|
// 1. 先同步 UI 状态为 false (由于用户已经拨动了开关)
|
||||||
|
this.isOnline = false
|
||||||
|
|
||||||
|
// 2. 弹出警告
|
||||||
|
uni.showModal({
|
||||||
|
title: '无法下线',
|
||||||
|
content: '您当前有正在进行的任务,请完成后再下线。',
|
||||||
|
showCancel: false,
|
||||||
|
success: (_) => {
|
||||||
|
// 3. 用户点击确定后或立即强制回弹开关为 true
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.isOnline = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 冗余保障:如果 Modal 没及时回弹,延时强制重置
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isOnline = true
|
||||||
|
}, 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isOnline = targetStatus
|
||||||
if (this.isOnline) {
|
if (this.isOnline) {
|
||||||
this.startWork()
|
this.startWork()
|
||||||
} else {
|
} else {
|
||||||
@@ -475,8 +511,15 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 开始工作
|
// 开始工作
|
||||||
startWork() {
|
async startWork() {
|
||||||
// TODO: 调用API开始工作,上传位置
|
const driverId = this.driverInfo.id
|
||||||
|
if (driverId != '') {
|
||||||
|
try {
|
||||||
|
await supa.from('ml_delivery_drivers').update({ work_status: 1 } as any).eq('id', driverId).execute()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('startWork update failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
this.loadAvailableOrders()
|
this.loadAvailableOrders()
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '已上线接单',
|
title: '已上线接单',
|
||||||
@@ -485,8 +528,15 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 停止工作
|
// 停止工作
|
||||||
stopWork() {
|
async stopWork() {
|
||||||
// TODO: 调用API停止工作
|
const driverId = this.driverInfo.id
|
||||||
|
if (driverId != '') {
|
||||||
|
try {
|
||||||
|
await supa.from('ml_delivery_drivers').update({ work_status: 0 } as any).eq('id', driverId).execute()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('stopWork update failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
this.availableOrders = []
|
this.availableOrders = []
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '已下线休息',
|
title: '已下线休息',
|
||||||
@@ -631,6 +681,29 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
if (res && !res.error) {
|
if (res && !res.error) {
|
||||||
const completedOrder = { ...this.currentTask }
|
const completedOrder = { ...this.currentTask }
|
||||||
uni.setStorageSync('completed_order_for_history', completedOrder)
|
uni.setStorageSync('completed_order_for_history', completedOrder)
|
||||||
|
// 同步更新 ml_orders 的状态,确保两个表状态一致
|
||||||
|
try {
|
||||||
|
// 尝试使用 currentTask.order_id(由 _transformTask 提供)
|
||||||
|
const orderId = (this.currentTask as any).order_id || ''
|
||||||
|
if (orderId) {
|
||||||
|
const upRes: any = await supa.from('ml_orders').update({ order_status: 5 }).eq('id', orderId).execute()
|
||||||
|
console.log('confirmDelivery: ml_orders update res=', upRes)
|
||||||
|
if (!upRes || upRes.error) console.warn('confirmDelivery: ml_orders update failed', upRes)
|
||||||
|
} else {
|
||||||
|
// 如无 order_id,回退读取任务行以查找 order_id
|
||||||
|
const tRes: any = await supa.from('ml_delivery_tasks').select('order_id').eq('id', this.currentTask.id).limit(1).execute()
|
||||||
|
if (tRes && Array.isArray(tRes.data) && tRes.data.length > 0) {
|
||||||
|
const oid = tRes.data[0].order_id
|
||||||
|
if (oid) {
|
||||||
|
const upRes2: any = await supa.from('ml_orders').update({ order_status: 5 }).eq('id', oid).execute()
|
||||||
|
console.log('confirmDelivery: ml_orders update (fallback) res=', upRes2)
|
||||||
|
if (!upRes2 || upRes2.error) console.warn('confirmDelivery: ml_orders update (fallback) failed', upRes2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (syncErr) {
|
||||||
|
console.warn('confirmDelivery: failed to sync ml_orders status', syncErr)
|
||||||
|
}
|
||||||
uni.showToast({ title: '配送完成', icon: 'success' })
|
uni.showToast({ title: '配送完成', icon: 'success' })
|
||||||
this.currentTask = null
|
this.currentTask = null
|
||||||
this.loadAvailableOrders()
|
this.loadAvailableOrders()
|
||||||
@@ -684,6 +757,21 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
const res = await supa.from('ml_delivery_tasks').update({ driver_id: driverId, status: 2 }).eq('id', orderId).execute()
|
const res = await supa.from('ml_delivery_tasks').update({ driver_id: driverId, status: 2 }).eq('id', orderId).execute()
|
||||||
if (res && !res.error) {
|
if (res && !res.error) {
|
||||||
uni.showToast({ title: '订单已接受', icon: 'success' })
|
uni.showToast({ title: '订单已接受', icon: 'success' })
|
||||||
|
// 同步更新 ml_orders 状态为已接取(2)
|
||||||
|
try {
|
||||||
|
// orderId 这里是 ml_delivery_tasks.id(task id),需要先获取 order_id
|
||||||
|
const tRes: any = await supa.from('ml_delivery_tasks').select('order_id').eq('id', orderId).limit(1).execute()
|
||||||
|
if (tRes && Array.isArray(tRes.data) && tRes.data.length > 0) {
|
||||||
|
const oid = tRes.data[0].order_id
|
||||||
|
if (oid) {
|
||||||
|
const upRes: any = await supa.from('ml_orders').update({ order_status: 2 }).eq('id', oid).execute()
|
||||||
|
console.log('acceptOrder: ml_orders update res=', upRes)
|
||||||
|
if (!upRes || upRes.error) console.warn('acceptOrder: ml_orders update failed', upRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (syncErr) {
|
||||||
|
console.warn('acceptOrder: failed to sync ml_orders status', syncErr)
|
||||||
|
}
|
||||||
await this.loadCurrentTask()
|
await this.loadCurrentTask()
|
||||||
await this.loadAvailableOrders()
|
await this.loadAvailableOrders()
|
||||||
}
|
}
|
||||||
@@ -728,7 +816,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
/* ... 保持原有 style 部分不变 ... */
|
/* ... 保持原有 style 部分不变 ... */
|
||||||
.delivery-container {
|
.delivery-container {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
@@ -1174,6 +1262,25 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts'
|
|||||||
border: 1rpx solid #ddd;
|
border: 1rpx solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 加载更多订单入口样式 */
|
||||||
|
.view-all-footer {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin: 10rpx 0 30rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1rpx dashed #4CAF50;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4CAF50;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
/* 历史记录快捷入口 */
|
/* 历史记录快捷入口 */
|
||||||
.quick-actions-section {
|
.quick-actions-section {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="delivery-order-detail">
|
<view class="delivery-order-detail">
|
||||||
|
<view class="page-content">
|
||||||
<!-- 返回按钮 -->
|
<!-- 返回按钮 -->
|
||||||
<view class="back-header">
|
<view class="back-header">
|
||||||
<view class="back-box" @click="goBack">
|
<view class="back-box" @click="goBack">
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
<view class="route-icon">📦</view>
|
<view class="route-icon">📦</view>
|
||||||
<view class="route-content">
|
<view class="route-content">
|
||||||
<text class="route-title">取货地址</text>
|
<text class="route-title">取货地址</text>
|
||||||
<text class="route-address">{{ merchant.contact_name }} · {{ merchant.contact_phone }}</text>
|
<text class="route-address">{{ merchant.contact_name ? (merchant.contact_name + ' · ') : '' }}{{ merchant.contact_phone }}</text>
|
||||||
<text class="route-detail">{{ pickupAddress }}</text>
|
<text class="route-detail">{{ pickupAddress }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 只在取货中状态且订单未完成时显示按钮 -->
|
<!-- 只在取货中状态且订单未完成时显示按钮 -->
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
<view class="route-icon">🏠</view>
|
<view class="route-icon">🏠</view>
|
||||||
<view class="route-content">
|
<view class="route-content">
|
||||||
<text class="route-title">送货地址</text>
|
<text class="route-title">送货地址</text>
|
||||||
<text class="route-address">{{ getDeliveryAddress().name }} · {{ getDeliveryAddress().phone }}</text>
|
<text class="route-address">{{ getDeliveryAddress().name ? (getDeliveryAddress().name + ' · ') : '' }}{{ (order.delivery_address as UTSJSONObject)['phone'] || '' }}</text>
|
||||||
<text class="route-detail">{{ getDeliveryAddress().detail }}</text>
|
<text class="route-detail">{{ getDeliveryAddress().detail }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 只在已取货状态且订单未完成时显示按钮 -->
|
<!-- 只在已取货状态且订单未完成时显示按钮 -->
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
<view class="contact-icon">📞</view>
|
<view class="contact-icon">📞</view>
|
||||||
<view class="contact-info">
|
<view class="contact-info">
|
||||||
<text class="contact-name">联系顾客</text>
|
<text class="contact-name">联系顾客</text>
|
||||||
<text class="contact-phone">{{ getDeliveryAddress().phone }}</text>
|
<text class="contact-phone">{{ (order.delivery_address as UTSJSONObject)['phone'] || '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="contact-item" @click="callMerchant">
|
<view class="contact-item" @click="callMerchant">
|
||||||
@@ -156,6 +157,8 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部操作 -->
|
<!-- 底部操作 -->
|
||||||
<view class="bottom-actions">
|
<view class="bottom-actions">
|
||||||
<!-- 只在待接单状态且订单未完成时显示接受/拒绝订单按钮 -->
|
<!-- 只在待接单状态且订单未完成时显示接受/拒绝订单按钮 -->
|
||||||
@@ -249,30 +252,78 @@ export default {
|
|||||||
async loadOrderDetail(orderId: string) {
|
async loadOrderDetail(orderId: string) {
|
||||||
const originalStatus = this.order.status
|
const originalStatus = this.order.status
|
||||||
try {
|
try {
|
||||||
await supaReady
|
// 使用 1.5s 超时策略包装 supaReady,防止 session 刷新卡死页面
|
||||||
console.log('loadOrderDetail called', { orderId })
|
const readyPromise = supaReady
|
||||||
|
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('supaReady timeout')), 1500))
|
||||||
|
try {
|
||||||
|
await Promise.race([readyPromise, timeoutPromise])
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadOrderDetail: supaReady timeout, proceeding with current session', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取订单主表:按 id(UUID) / cid(数字) / order_no 三种可能匹配
|
console.log('loadOrderDetail: start loading', { orderId, originalStatus })
|
||||||
|
uni.showLoading({ title: '加载中...' })
|
||||||
|
|
||||||
|
// 配送端详情页逻辑:
|
||||||
|
// 1. 先尝试直接查 ml_orders (传入的是真实订单ID时)
|
||||||
|
// 2. 如果没查到,尝试从 ml_delivery_tasks 查找 (传入的是任务ID时)
|
||||||
|
// 3. 如果从任务表查到了,再拿其关联的 order_id 回查 ml_orders
|
||||||
|
|
||||||
|
let targetOrderId = orderId
|
||||||
let orderRes: any = null
|
let orderRes: any = null
|
||||||
|
let taskData: any = null
|
||||||
|
|
||||||
const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(orderId)
|
const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(orderId)
|
||||||
const isNumber = /^\d+$/.test(orderId)
|
const isNumber = /^\d+$/.test(orderId)
|
||||||
console.log('loadOrderDetail: supa session=', supa.getSession && supa.getSession())
|
|
||||||
console.log('loadOrderDetail: detect id format', { isUuid, isNumber })
|
console.log('loadOrderDetail: detect id format', { isUuid, isNumber })
|
||||||
|
|
||||||
|
// 步骤 A: 查 ml_orders
|
||||||
if (isUuid) {
|
if (isUuid) {
|
||||||
console.log('loadOrderDetail: querying by id')
|
|
||||||
orderRes = await supa.from('ml_orders').select('*').eq('id', orderId).limit(1).execute()
|
orderRes = await supa.from('ml_orders').select('*').eq('id', orderId).limit(1).execute()
|
||||||
} else if (isNumber) {
|
} else if (isNumber) {
|
||||||
console.log('loadOrderDetail: querying by cid')
|
|
||||||
orderRes = await supa.from('ml_orders').select('*').eq('cid', Number(orderId)).limit(1).execute()
|
orderRes = await supa.from('ml_orders').select('*').eq('cid', Number(orderId)).limit(1).execute()
|
||||||
} else {
|
} else {
|
||||||
console.log('loadOrderDetail: querying by order_no')
|
|
||||||
orderRes = await supa.from('ml_orders').select('*').eq('order_no', orderId).limit(1).execute()
|
orderRes = await supa.from('ml_orders').select('*').eq('order_no', orderId).limit(1).execute()
|
||||||
}
|
}
|
||||||
console.log('loadOrderDetail: orderRes=', orderRes)
|
|
||||||
|
// 步骤 B: 如果订单没查到,尝试从任务表查
|
||||||
|
if (!(orderRes && Array.isArray(orderRes.data) && orderRes.data.length > 0)) {
|
||||||
|
console.log('loadOrderDetail: order not found directly, checking ml_delivery_tasks')
|
||||||
|
const taskRes: any = await supa.from('ml_delivery_tasks').select('*').eq('id', orderId).limit(1).execute()
|
||||||
|
if (taskRes && Array.isArray(taskRes.data) && taskRes.data.length > 0) {
|
||||||
|
taskData = taskRes.data[0]
|
||||||
|
console.log('loadOrderDetail: found task record', taskData)
|
||||||
|
if (taskData.order_id) {
|
||||||
|
targetOrderId = taskData.order_id
|
||||||
|
console.log('loadOrderDetail: found linked order_id in task', targetOrderId)
|
||||||
|
orderRes = await supa.from('ml_orders').select('*').eq('id', targetOrderId).limit(1).execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('loadOrderDetail: final orderRes data length=', orderRes?.data?.length)
|
||||||
|
|
||||||
if (orderRes && Array.isArray(orderRes.data) && orderRes.data.length > 0) {
|
if (orderRes && Array.isArray(orderRes.data) && orderRes.data.length > 0) {
|
||||||
const row = orderRes.data[0]
|
const row = orderRes.data[0]
|
||||||
|
console.log('loadOrderDetail: discovered order details', row)
|
||||||
const shipping = row.shipping_address || {}
|
|
||||||
|
// 如果没有预先获取 taskData,在这里查一次
|
||||||
|
if (!taskData) {
|
||||||
|
const dtQuery: any = await supa.from('ml_delivery_tasks').select('*').eq('order_id', row.id).limit(1).execute()
|
||||||
|
if (dtQuery && Array.isArray(dtQuery.data) && dtQuery.data.length > 0) taskData = dtQuery.data[0]
|
||||||
|
}
|
||||||
|
let shipping: UTSJSONObject = {} as UTSJSONObject
|
||||||
|
try {
|
||||||
|
const rawShipping = row.shipping_address
|
||||||
|
if (typeof rawShipping == 'string' && (rawShipping as string).startsWith('{')) {
|
||||||
|
shipping = JSON.parse(rawShipping as string) as UTSJSONObject
|
||||||
|
} else if (rawShipping != null && typeof rawShipping == 'object') {
|
||||||
|
shipping = rawShipping as UTSJSONObject
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadOrderDetail: parse shipping_address failed', e)
|
||||||
|
}
|
||||||
|
|
||||||
this.order = Object.assign(this.order, {
|
this.order = Object.assign(this.order, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -287,9 +338,9 @@ export default {
|
|||||||
payment_method: row.payment_method || 0,
|
payment_method: row.payment_method || 0,
|
||||||
payment_status: row.payment_status || 0,
|
payment_status: row.payment_status || 0,
|
||||||
delivery_address: {
|
delivery_address: {
|
||||||
name: shipping.name || shipping.recipient || '',
|
name: shipping['name'] || shipping['recipient'] || '',
|
||||||
phone: shipping.phone || shipping.mobile || '',
|
phone: shipping['phone'] || shipping['mobile'] || '',
|
||||||
detail: shipping.detail || shipping.address || JSON.stringify(shipping)
|
detail: shipping['detail'] || shipping['address'] || JSON.stringify(shipping)
|
||||||
},
|
},
|
||||||
created_at: row.created_at || ''
|
created_at: row.created_at || ''
|
||||||
})
|
})
|
||||||
@@ -341,19 +392,63 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deliveryInfo 从 ml_delivery_tasks 中读取(如果存在)
|
// deliveryInfo 从 ml_delivery_tasks 中读取(如果存在)
|
||||||
const dtRes: any = await supa.from('ml_delivery_tasks').select('*').eq('order_id', realOrderId).limit(1).execute()
|
if (taskData) {
|
||||||
console.log('loadOrderDetail: dtRes=', dtRes)
|
this.deliveryInfo.distance = Number(taskData.distance) || this.deliveryInfo.distance
|
||||||
if (dtRes && Array.isArray(dtRes.data) && dtRes.data.length > 0) {
|
this.deliveryInfo.estimated_time = Number(taskData.estimated_time) || this.deliveryInfo.estimated_time
|
||||||
const dt = dtRes.data[0]
|
this.deliveryInfo.courier_id = taskData.driver_id || ''
|
||||||
this.deliveryInfo.distance = Number(dt.distance) || this.deliveryInfo.distance
|
this.deliveryInfo.pickup_time = taskData.pickup_time || ''
|
||||||
this.deliveryInfo.estimated_time = Number(dt.estimated_time) || this.deliveryInfo.estimated_time
|
this.deliveryInfo.delivery_time = taskData.delivered_time || taskData.delivered_at || ''
|
||||||
this.deliveryInfo.courier_id = dt.driver_id || ''
|
|
||||||
this.deliveryInfo.pickup_time = dt.pickup_time || ''
|
|
||||||
this.deliveryInfo.delivery_time = dt.delivered_time || ''
|
|
||||||
}
|
}
|
||||||
|
} else if (taskData) {
|
||||||
|
// ⚠️ 数据不一致兜底:订单主表查不到,但任务表查到了
|
||||||
|
console.warn('loadOrderDetail: order missing but task found. Using fallback.')
|
||||||
|
|
||||||
|
const parseF = (v: any) => {
|
||||||
|
if (!v) return { detail: '', name: '', phone: '' }
|
||||||
|
let o: any = v
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
try { o = JSON.parse(v) } catch (e) { o = { detail: v } }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: o.detail || o.address || o.full_address || '',
|
||||||
|
name: o.name || o.contact_name || o.recipient_name || '',
|
||||||
|
phone: o.phone || o.mobile || o.contact_phone || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickup = parseF(taskData.pickup_address)
|
||||||
|
const delivery = parseF(taskData.delivery_address)
|
||||||
|
const pContact = parseF(taskData.pickup_contact)
|
||||||
|
const dContact = parseF(taskData.delivery_contact)
|
||||||
|
|
||||||
|
this.order = Object.assign(this.order, {
|
||||||
|
id: taskData.order_id || taskData.id,
|
||||||
|
order_no: taskData.order_no || taskData.id.substring(0, 8),
|
||||||
|
status: Number(taskData.status) || originalStatus,
|
||||||
|
delivery_address: {
|
||||||
|
name: dContact.name || delivery.name,
|
||||||
|
phone: dContact.phone || delivery.phone,
|
||||||
|
detail: delivery.detail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.merchant.contact_name = pContact.name || pickup.name
|
||||||
|
this.merchant.contact_phone = pContact.phone || pickup.phone
|
||||||
|
this.pickupAddress = pickup.detail
|
||||||
|
|
||||||
|
this.deliveryInfo.distance = Number(taskData.distance) || 0
|
||||||
|
this.deliveryInfo.estimated_time = Number(taskData.estimated_time) || 0
|
||||||
|
|
||||||
|
uni.showToast({ title: '已回退从任务记录显示', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
console.warn('loadOrderDetail: no order found for id', orderId)
|
||||||
|
uni.showToast({ title: '未找到订单或任务信息', icon: 'none' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('loadOrderDetail db error', e)
|
console.error('loadOrderDetail db error', e)
|
||||||
|
uni.showToast({ title: '加载订单失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -530,7 +625,15 @@ export default {
|
|||||||
.delivery-order-detail {
|
.delivery-order-detail {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-bottom: 160rpx;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 0; /* padding moved to .page-content to avoid double spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding-bottom: 300rpx; /* 留出底部操作区域高度,确保联系商家等内容可滚动到最下 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ... 其余样式保持原样 ... */
|
/* ... 其余样式保持原样 ... */
|
||||||
@@ -738,7 +841,8 @@ export default {
|
|||||||
|
|
||||||
.order-no {
|
.order-no {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #666;
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-item {
|
.product-item {
|
||||||
@@ -865,30 +969,38 @@ export default {
|
|||||||
.contact-item {
|
.contact-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column; /* 改为垂直排列避免空间不足 */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 25rpx;
|
justify-content: center;
|
||||||
|
padding: 20rpx;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border-radius: 10rpx;
|
border-radius: 10rpx;
|
||||||
|
min-height: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-icon {
|
.contact-icon {
|
||||||
font-size: 32rpx;
|
font-size: 36rpx;
|
||||||
margin-right: 15rpx;
|
margin-bottom: 10rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-info {
|
.contact-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-name {
|
.contact-name {
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
color: #333;
|
color: #666;
|
||||||
margin-bottom: 5rpx;
|
margin-bottom: 5rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-phone {
|
.contact-phone {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: #007aff;
|
color: #007aff;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-actions {
|
.bottom-actions {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-actions">
|
<view class="order-actions">
|
||||||
<button class="action-btn primary" @click="viewOrderDetail(order.id, order.status)">查看详情</button>
|
<button class="action-btn primary" @click="viewOrderDetail(order)">查看详情</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -93,11 +93,15 @@ export default {
|
|||||||
checkForNewCompletedOrder() {
|
checkForNewCompletedOrder() {
|
||||||
const completedOrderFromStorage = uni.getStorageSync('completed_order_for_history')
|
const completedOrderFromStorage = uni.getStorageSync('completed_order_for_history')
|
||||||
if (completedOrderFromStorage) {
|
if (completedOrderFromStorage) {
|
||||||
// 如果有,将其添加到订单列表的开头
|
// 仅在本地存储条目标记为已完成(status >= 4)时才合并到历史
|
||||||
// 检查是否已经存在于列表中,避免重复添加
|
const storedStatus = Number(completedOrderFromStorage && (completedOrderFromStorage.status ?? 0))
|
||||||
const exists = this.orderList.some(order => order.id === completedOrderFromStorage.id)
|
if (storedStatus >= 4) {
|
||||||
if (!exists) {
|
const exists = this.orderList.some(order => order.id === completedOrderFromStorage.id)
|
||||||
this.orderList.unshift(completedOrderFromStorage)
|
if (!exists) {
|
||||||
|
this.orderList.unshift(completedOrderFromStorage)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('checkForNewCompletedOrder: ignoring stored completed_order_for_history with non-completed status', completedOrderFromStorage)
|
||||||
}
|
}
|
||||||
// 清除本地存储,防止下次进入页面时重复添加
|
// 清除本地存储,防止下次进入页面时重复添加
|
||||||
uni.removeStorageSync('completed_order_for_history')
|
uni.removeStorageSync('completed_order_for_history')
|
||||||
@@ -107,122 +111,114 @@ export default {
|
|||||||
// 加载历史订单(从数据库读取)
|
// 加载历史订单(从数据库读取)
|
||||||
async loadOrderHistory() {
|
async loadOrderHistory() {
|
||||||
try {
|
try {
|
||||||
const ready = await Promise.race([supaReady, new Promise(resolve => setTimeout(() => resolve(false), 1500))])
|
await supaReady
|
||||||
if (!ready) console.warn('supaReady timeout/failed in loadOrderHistory - proceeding')
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('supaReady failed', e)
|
console.warn('supaReady failed', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure profile is loaded so getCurrentUserId returns ak_users.id when available
|
|
||||||
try {
|
|
||||||
await getCurrentUser()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('getCurrentUser failed in loadOrderHistory', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uid = getCurrentUserId()
|
const uid = getCurrentUserId()
|
||||||
console.log('loadOrderHistory: currentUserId=', uid)
|
console.log('loadOrderHistory: currentUserId=', uid)
|
||||||
|
|
||||||
// 首先查 ml_delivery_tasks 中分配给当前用户的任务(仅包含已接/分配的 status >= 2)
|
// 首先尝试解析出 ml_delivery_drivers 的 id(driver_id),避免直接使用 auth user id 导致不匹配
|
||||||
let dtRes: any = { data: [] }
|
let driverId: string | null = null
|
||||||
try {
|
try {
|
||||||
let queryUid = uid
|
if (uid && uid !== '') {
|
||||||
// 如果 uid 为空,尝试从 supa session 获取 auth id 并映射到 ak_users.id
|
// 尝试直接按 user_id 查找 driver
|
||||||
if (!queryUid || queryUid === '') {
|
const drvRes: any = await supa.from('ml_delivery_drivers').select('id').eq('user_id', uid).limit(1).execute()
|
||||||
try {
|
if (drvRes && Array.isArray(drvRes.data) && drvRes.data.length > 0) {
|
||||||
const sess = supa.getSession && supa.getSession()
|
driverId = drvRes.data[0].id
|
||||||
const sessId = sess && sess.user && sess.user.getString && sess.user.getString('id')
|
} else {
|
||||||
console.log('loadOrderHistory: session id fallback=', sessId)
|
// 回退:尝试 ak_users 表根据 auth_id 查出 ak_users.id,再查 ml_delivery_drivers
|
||||||
if (sessId) {
|
const akRes: any = await supa.from('ak_users').select('id').eq('auth_id', uid).limit(1).execute()
|
||||||
const akRes = await supa.from('ak_users').select('id').eq('auth_id', sessId).limit(1).execute()
|
if (akRes && Array.isArray(akRes.data) && akRes.data.length > 0) {
|
||||||
if (akRes && Array.isArray(akRes.data) && akRes.data.length > 0) {
|
const akId = akRes.data[0].id
|
||||||
queryUid = (akRes.data[0] as any).id
|
const drvRes2: any = await supa.from('ml_delivery_drivers').select('id').eq('user_id', akId).limit(1).execute()
|
||||||
console.log('loadOrderHistory: mapped ak_users.id=', queryUid)
|
if (drvRes2 && Array.isArray(drvRes2.data) && drvRes2.data.length > 0) {
|
||||||
|
driverId = drvRes2.data[0].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (mapErr) {
|
|
||||||
console.warn('loadOrderHistory: ak_users mapping failed', mapErr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadOrderHistory: driver lookup failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
if (queryUid && queryUid !== '') {
|
// 直接以 ml_delivery_tasks 作为数据源(配送端真源)
|
||||||
dtRes = await supa.from('ml_delivery_tasks')
|
let tasksRes: any = { data: [] }
|
||||||
.select('order_id,status')
|
try {
|
||||||
.eq('driver_id', queryUid)
|
if (driverId) {
|
||||||
|
tasksRes = await supa.from('ml_delivery_tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('driver_id', driverId)
|
||||||
.gte('status', 2)
|
.gte('status', 2)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(200)
|
.limit(200)
|
||||||
.execute()
|
.execute()
|
||||||
} else {
|
} else {
|
||||||
dtRes = { data: [] }
|
tasksRes = { data: [] }
|
||||||
}
|
}
|
||||||
console.log('loadOrderHistory: delivery_tasks dtRes=', dtRes)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadOrderHistory: delivery_tasks query failed', err)
|
console.error('loadOrderHistory: ml_delivery_tasks query failed', err)
|
||||||
dtRes = { data: [] }
|
tasksRes = { data: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderIds = (dtRes && Array.isArray(dtRes.data)) ? dtRes.data.map((r: any) => r.order_id) : []
|
console.log('loadOrderHistory: tasksRes=', tasksRes)
|
||||||
|
|
||||||
// 如果没有通过 delivery_tasks 找到订单,改为直接读取最近完成/已取货订单(兼容测试环境)
|
// 如果任务包含 order_id,则从 ml_orders 查询 order_no 用于显示
|
||||||
let ordersRes
|
const orderIdsFromTasks = (tasksRes && Array.isArray(tasksRes.data)) ? tasksRes.data.map((r: any) => r.order_id).filter(Boolean) : []
|
||||||
|
let ordersRes: any = { data: [] }
|
||||||
try {
|
try {
|
||||||
if (orderIds.length > 0) {
|
if (orderIdsFromTasks.length > 0) {
|
||||||
ordersRes = await supa.from('ml_orders').select('*').in('id', orderIds).order('created_at', { ascending: false }).limit(200).execute()
|
ordersRes = await supa.from('ml_orders').select('id,order_no').in('id', orderIdsFromTasks).execute()
|
||||||
} else {
|
|
||||||
ordersRes = await supa.from('ml_orders').select('*').in('order_status', [4,5]).order('created_at', { ascending: false }).limit(200).execute()
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadOrderHistory: ml_orders query failed', err)
|
console.warn('loadOrderHistory: ml_orders lookup failed', err)
|
||||||
ordersRes = { data: [] }
|
ordersRes = { data: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('loadOrderHistory: ordersRes=', ordersRes)
|
console.log('loadOrderHistory: ordersRes=', ordersRes)
|
||||||
|
|
||||||
const mapOrder = (r: any) => ({
|
const orderNoMap: Record<string,string> = {}
|
||||||
id: r.id,
|
if (ordersRes && Array.isArray(ordersRes.data)) {
|
||||||
order_no: r.order_no || String(r.cid || ''),
|
ordersRes.data.forEach((o: any) => { if (o && o.id) orderNoMap[o.id] = o.order_no })
|
||||||
status: r.order_status ?? r.status ?? 0,
|
}
|
||||||
pickup_address: r.pickup_address || r.shipping_address || { detail: '', area: '' },
|
|
||||||
delivery_address: r.delivery_address || r.shipping_address || { detail: '', area: '' },
|
const parseAddress = (a: any) => {
|
||||||
pickup_contact: r.pickup_contact || r.shipping_contact || { name: '', phone: '' },
|
if (!a) return { detail: '', area: '' }
|
||||||
delivery_contact: r.delivery_contact || r.shipping_contact || { name: '', phone: '' },
|
let obj = a
|
||||||
delivery_fee: r.delivery_fee || r.delivery_fees || 0,
|
if (typeof a === 'string') {
|
||||||
distance: r.distance || 0,
|
try { obj = JSON.parse(a) } catch (e) { obj = { detail: a } }
|
||||||
estimated_time: r.estimated_time || 0,
|
}
|
||||||
created_at: r.created_at
|
const detail = obj.detail || obj.address || obj.full_address || obj.address_detail || obj.name || ''
|
||||||
|
const area = (obj.city || obj.district || obj.area || '')
|
||||||
|
return { detail, area }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseContact = (c: any) => {
|
||||||
|
if (!c) return { name: '', phone: '' }
|
||||||
|
let obj = c
|
||||||
|
if (typeof c === 'string') {
|
||||||
|
try { obj = JSON.parse(c) } catch (e) { obj = { name: c } }
|
||||||
|
}
|
||||||
|
return { name: obj.name || obj.contact_name || obj.receiver_name || '', phone: obj.phone || obj.mobile || obj.contact_phone || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapTaskToOrder = (t: any) => ({
|
||||||
|
id: t.id,
|
||||||
|
// 优先使用任务自身的 order_no 字段(若存在),否则使用从 ml_orders 查询到的 order_no
|
||||||
|
order_no: t.order_no || t.orderNo || t.trade_no || orderNoMap[t.order_id] || '',
|
||||||
|
status: Number(t.status) || 0,
|
||||||
|
pickup_address: parseAddress(t.pickup_address),
|
||||||
|
delivery_address: parseAddress(t.delivery_address),
|
||||||
|
pickup_contact: parseContact(t.pickup_contact),
|
||||||
|
delivery_contact: parseContact(t.delivery_contact),
|
||||||
|
delivery_fee: Number(t.delivery_fee) || 0,
|
||||||
|
distance: Number(t.distance) || 0,
|
||||||
|
estimated_time: Number(t.estimated_time) || 0,
|
||||||
|
created_at: t.created_at,
|
||||||
|
order_id: t.order_id || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
this.orderList = (ordersRes && ordersRes.data) ? ordersRes.data.map(mapOrder) : []
|
this.orderList = (tasksRes && Array.isArray(tasksRes.data)) ? tasksRes.data.map(mapTaskToOrder) : []
|
||||||
|
|
||||||
// 额外:把当前分配给本司机但尚未完成的任务对应订单也展示在列表顶部(便于查看当前任务)
|
|
||||||
try {
|
|
||||||
if (uid && uid !== '') {
|
|
||||||
// 仅获取已经被接取/分配给本司机的任务(status >= 2),包括进行中和已取货但未完成的
|
|
||||||
const taskRes: any = await supa.from('ml_delivery_tasks')
|
|
||||||
.select('order_id,status')
|
|
||||||
.eq('driver_id', uid)
|
|
||||||
.gte('status', 2)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(50)
|
|
||||||
.execute()
|
|
||||||
if (taskRes && Array.isArray(taskRes.data) && taskRes.data.length > 0) {
|
|
||||||
const taskOrderIds = taskRes.data.map((t: any) => t.order_id).filter((id: any) => id)
|
|
||||||
if (taskOrderIds.length > 0) {
|
|
||||||
const curOrdersRes: any = await supa.from('ml_orders').select('*').in('id', taskOrderIds).execute()
|
|
||||||
const curMapped = (curOrdersRes && Array.isArray(curOrdersRes.data)) ? curOrdersRes.data.map(mapOrder) : []
|
|
||||||
// 把当前任务对应订单插入到列表最前面(避免重复)
|
|
||||||
curMapped.forEach((o: any) => {
|
|
||||||
if (!this.orderList.some((ex: any) => ex.id === o.id)) {
|
|
||||||
this.orderList.unshift(o)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('loadOrderHistory: fetch current tasks failed', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有新完成的订单(在加载初始数据后)
|
// 检查是否有新完成的订单(在加载初始数据后)
|
||||||
this.checkForNewCompletedOrder()
|
this.checkForNewCompletedOrder()
|
||||||
@@ -253,9 +249,11 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 查看订单详情
|
// 查看订单详情
|
||||||
viewOrderDetail(orderId: string, status: number) {
|
viewOrderDetail(order: any) {
|
||||||
|
// 优先传递 ml_orders.id,但如果订单表对应行确实缺失,详情页逻辑现在支持用 Task ID 回退显示
|
||||||
|
const targetId = order.order_id || order.id
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/mall/delivery/order-detail?id=${orderId}&status=${status}`
|
url: `/pages/mall/delivery/order-detail?id=${targetId}&status=${order.status}`
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -269,7 +267,7 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss">
|
||||||
.order-history-container {
|
.order-history-container {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ function backToIndex() {
|
|||||||
uni.navigateBack({ url: '/pages/mall/delivery/index' })
|
uni.navigateBack({ url: '/pages/mall/delivery/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 兼容模板中的 goBack 绑定,保持与其它页面一致 */
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------- 数据 & 状态 ----------------- */
|
/* ----------------- 数据 & 状态 ----------------- */
|
||||||
const driverInfo = ref({ id: '', real_name: '配送员', avatar_url: '', rating: 4.9, total_orders: 0, work_status: 1 })
|
const driverInfo = ref({ id: '', real_name: '配送员', avatar_url: '', rating: 4.9, total_orders: 0, work_status: 1 })
|
||||||
const workStatus = ref(1)
|
const workStatus = ref(1)
|
||||||
|
|||||||
349
pages/mall/delivery/test/api-simulator.uvue
Normal file
349
pages/mall/delivery/test/api-simulator.uvue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">第三方物流 API 模拟发送器</text>
|
||||||
|
<text class="subtitle">模拟外部物流平台向后端推送 Webhook 轨迹数据</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">1. 选择目标订单 (已发货)</text>
|
||||||
|
<scroll-view class="order-list" direction="horizontal">
|
||||||
|
<view v-for="(item, index) in shippedOrders" :key="index"
|
||||||
|
:class="['order-card', selectedOrderIndex == index ? 'active' : '']"
|
||||||
|
@click="selectOrder(index)">
|
||||||
|
<text class="order-no">{{ item.order_no }}</text>
|
||||||
|
<text class="tracking-no">{{ item.carrier }}: {{ item.tracking_no }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">2. 构造协议数据 (YTO Protocol)</text>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">物流单号 (mailNo):</text>
|
||||||
|
<input class="input" v-model="form.mailNo" placeholder="请输入运单号" />
|
||||||
|
</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">订单号 (txLogisticId):</text>
|
||||||
|
<input class="input" v-model="form.txLogisticId" placeholder="请输入关联订单号" />
|
||||||
|
</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">事件状态 (infoContent):</text>
|
||||||
|
<picker :range="statusOptions" range-key="label" @change="onStatusChange">
|
||||||
|
<view class="picker-val">{{ currentStatusLabel }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">轨迹描述 (remark):</text>
|
||||||
|
<textarea class="textarea" v-model="form.remark" placeholder="描述当前的物流位置或状态..." />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="payload-preview">
|
||||||
|
<text class="preview-title">接口发送原始数据预览:</text>
|
||||||
|
<view class="code-block">
|
||||||
|
<text class="code-text">{{ jsonString }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn-send" type="primary" @click="sendWebhook">立即推送 API 数据</button>
|
||||||
|
|
||||||
|
<view class="footer-links">
|
||||||
|
<text class="link" @click="goToLogs">查看接收日志 (Webhook Logs)</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="uts">
|
||||||
|
import { mockService, MockOrder } from './mock-service.uts'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
|
const orders = ref([] as MockOrder[])
|
||||||
|
const shippedOrders = computed((): MockOrder[] => {
|
||||||
|
return orders.value.filter((o: MockOrder): boolean => o.status !== 'PENDING' && o.tracking_no !== '')
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
// 使用不带用户过滤的接口,加载所有运单,便于模拟推送测试
|
||||||
|
const data = await mockService.getAllOrders()
|
||||||
|
orders.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadOrders()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOrderIndex = ref(-1)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
mailNo: '',
|
||||||
|
txLogisticId: '',
|
||||||
|
infoContent: 'SEND',
|
||||||
|
remark: '快件已到达【XX分拨中心】,准备发往下一站',
|
||||||
|
acceptTime: '',
|
||||||
|
carrier: '顺丰速运'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '已揽收 (GOT)', value: 'GOT' },
|
||||||
|
{ label: '运输中 (SEND)', value: 'SEND' },
|
||||||
|
{ label: '派送中 (SENT)', value: 'SENT' },
|
||||||
|
{ label: '待取件 (PICKUP)', value: 'PICKUP' },
|
||||||
|
{ label: '已签收 (SIGNED)', value: 'SIGNED' },
|
||||||
|
{ label: '异常 (FAILED)', value: 'FAILED' },
|
||||||
|
{ label: '退回 (RETURNED)', value: 'RETURNED' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentStatusLabel = computed((): string => {
|
||||||
|
const opt = statusOptions.find((o: UTSJSONObject): boolean => o['value'] === form.infoContent)
|
||||||
|
return (opt != null) ? opt['label'] as string : '请选择'
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonString = computed((): string => {
|
||||||
|
return JSON.stringify(form, null, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectOrder(index: number) {
|
||||||
|
selectedOrderIndex.value = index
|
||||||
|
const order = shippedOrders.value[index]
|
||||||
|
form.mailNo = order.tracking_no
|
||||||
|
form.txLogisticId = order.order_no
|
||||||
|
form.carrier = order.carrier + '速递'
|
||||||
|
|
||||||
|
// 根据订单当前状态智能预设
|
||||||
|
if (order.status === 'SHIPPED') {
|
||||||
|
form.infoContent = 'SEND'
|
||||||
|
form.remark = '快件已到达北京分拨中心'
|
||||||
|
} else if (order.status === 'IN_TRANSIT') {
|
||||||
|
form.infoContent = 'SENT'
|
||||||
|
form.remark = '派送员王师傅(13700008888)正在派件'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange(e: UniPickerChangeEvent) {
|
||||||
|
const idx = e.detail.value as number
|
||||||
|
form.infoContent = statusOptions[idx].value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWebhook() {
|
||||||
|
if (!form.mailNo) {
|
||||||
|
uni.showToast({ title: '请先填写运单号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查单号对应的订单是否已签收
|
||||||
|
const targetOrder = orders.value.find((o: MockOrder): boolean => o.tracking_no === form.mailNo)
|
||||||
|
if (targetOrder != null && targetOrder.status === 'DELIVERED') {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '该订单已显示已签收,无需继续推送物流动态。',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前时间戳作为圆通要求的 acceptTime
|
||||||
|
const now = new Date()
|
||||||
|
const Y = now.getFullYear()
|
||||||
|
const M = (now.getMonth() + 1).toString().padStart(2, '0')
|
||||||
|
const D = now.getDate().toString().padStart(2, '0')
|
||||||
|
const h = now.getHours().toString().padStart(2, '0')
|
||||||
|
const m = now.getMinutes().toString().padStart(2, '0')
|
||||||
|
const s = now.getSeconds().toString().padStart(2, '0')
|
||||||
|
form.acceptTime = `${Y}-${M}-${D} ${h}:${m}:${s}`
|
||||||
|
|
||||||
|
// 执行模拟推送 (转换为普通对象以兼容 UTS)
|
||||||
|
const payload = {
|
||||||
|
mailNo: form.mailNo,
|
||||||
|
txLogisticId: form.txLogisticId,
|
||||||
|
infoContent: form.infoContent,
|
||||||
|
remark: form.remark,
|
||||||
|
acceptTime: form.acceptTime,
|
||||||
|
carrier: form.carrier
|
||||||
|
} as UTSJSONObject
|
||||||
|
|
||||||
|
uni.showLoading({ title: '正在推送至数据库...' })
|
||||||
|
const success = await mockService.pushWebhookData(payload)
|
||||||
|
uni.hideLoading()
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
uni.showToast({ title: 'API 发送成功!', icon: 'success' })
|
||||||
|
// 成功后刷新列表,更新订单状态
|
||||||
|
loadOrders()
|
||||||
|
} else {
|
||||||
|
uni.showModal({
|
||||||
|
title: '发送失败',
|
||||||
|
content: '系统未找到该运单号,后端拒绝接收该数据。',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLogs() {
|
||||||
|
uni.navigateTo({ url: '/pages/mall/delivery/test/webhook-logs' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #007aff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-list {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
display: inline-block;
|
||||||
|
width: 160px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f0f4f8;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card.active {
|
||||||
|
border-color: #007aff;
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-no {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-val {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #007aff;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-preview {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-text {
|
||||||
|
color: #69f0ae;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #007aff;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
521
pages/mall/delivery/test/consumer-logistics-detail.uvue
Normal file
521
pages/mall/delivery/test/consumer-logistics-detail.uvue
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<!-- 头部状态栏 (沉浸式) -->
|
||||||
|
<view class="status-header">
|
||||||
|
<text class="back-link-white" @click="goBack">⬅ 返回</text>
|
||||||
|
<view class="status-main">
|
||||||
|
<text class="status-icon">{{ getStatusIcon(order.status) }}</text>
|
||||||
|
<view class="status-text-wrap">
|
||||||
|
<text class="status-title">{{ getStatusText(order.status) }}</text>
|
||||||
|
<view class="status-desc-row">
|
||||||
|
<text v-for="(seg, i) in splitText(getLatestEventText(), order.status)" :key="i" class="status-desc-text" :class="{ 'highlight': seg.isPhone }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 承运信息卡片 -->
|
||||||
|
<view class="card carrier-card">
|
||||||
|
<view class="carrier-info">
|
||||||
|
<image class="carrier-logo" src="/static/carrier-default.png" mode="aspectFit" />
|
||||||
|
<view class="carrier-detail">
|
||||||
|
<text class="carrier-name">{{ order.carrier }}快递</text>
|
||||||
|
<text v-if="order.tracking_no" class="waybill-no">运单号: {{ order.tracking_no }}</text>
|
||||||
|
<text v-else class="waybill-no">运单号: 暂无</text>
|
||||||
|
</view>
|
||||||
|
<button v-if="order.tracking_no" class="copy-btn" @click="copyTrackingNo">复制</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 物流轨迹时间线 -->
|
||||||
|
<view class="card timeline-card">
|
||||||
|
<view class="section-title">物流追踪</view>
|
||||||
|
<view class="timeline-container">
|
||||||
|
<view v-for="(event, index) in statusHistory" :key="index" class="timeline-item">
|
||||||
|
<!-- 时间轴线 -->
|
||||||
|
<view class="axis">
|
||||||
|
<view class="dot" :class="{active: index === 0}"></view>
|
||||||
|
<view class="line" v-if="index !== statusHistory.length - 1"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<view class="content">
|
||||||
|
<view class="event-header">
|
||||||
|
<view class="status-time-row">
|
||||||
|
<text v-if="index === 0 || event.status_code !== statusHistory[index - 1].status_code" class="status-label" :class="{active: index === 0}">{{ getStatusLabel(event.status_code) }}</text>
|
||||||
|
<text class="event-time" :class="{active: index === 0, 'margin-left': index === 0 || event.status_code !== statusHistory[index - 1].status_code}">{{ event.event_time }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="event-title-row">
|
||||||
|
<text v-for="(seg, i) in splitText(event.event_text, event.status_code)" :key="i" class="event-title-text" :class="{ 'highlight': seg.isPhone, 'active': index === 0 }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 证据/签收图片 (POD) -->
|
||||||
|
<view v-if="event.evidence_urls.length > 0" class="evidence-grid">
|
||||||
|
<image v-for="(img, idx) in event.evidence_urls" :key="idx" :src="img" class="evidence-img" mode="aspectFill" @click="previewImage(img, event.evidence_urls)" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="statusHistory.length === 0" class="empty-state">
|
||||||
|
<text class="empty-text">暂无物流动态</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 订单交易信息 -->
|
||||||
|
<view class="card order-info-card">
|
||||||
|
<view class="section-title">订单信息</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="info-label">订单编号</text>
|
||||||
|
<text class="info-value">{{ order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="info-label">下单时间</text>
|
||||||
|
<text class="info-value">{{ order.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="info-label">实付金额</text>
|
||||||
|
<text class="info-value">¥{{ order.amount || '0.00' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.address" class="info-item">
|
||||||
|
<text class="info-label">收货地址</text>
|
||||||
|
<text class="info-value">{{ order.address }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<view class="bottom-actions">
|
||||||
|
<button v-if="order.status === 'READY_FOR_PICKUP'" class="action-item primary" @click="confirmReceipt">确认取件</button>
|
||||||
|
<button class="action-item" @click="refresh">刷新物流</button>
|
||||||
|
<button class="action-item" @click="showSupport">物流客服</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import { mockService, MockOrder, MockTrackingEvent, LogisticsConstants } from './mock-service.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orderNo: 'ORD20260205002',
|
||||||
|
order: {
|
||||||
|
order_no: '',
|
||||||
|
status: '',
|
||||||
|
created_at: '',
|
||||||
|
receiver_name: '',
|
||||||
|
receiver_masked_phone: '',
|
||||||
|
address: '',
|
||||||
|
amount: '',
|
||||||
|
carrier: '',
|
||||||
|
tracking_no: ''
|
||||||
|
} as MockOrder,
|
||||||
|
statusHistory: [] as MockTrackingEvent[]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options: any) {
|
||||||
|
if (options['order_no'] != null) {
|
||||||
|
this.orderNo = options['order_no'] as string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async loadData() {
|
||||||
|
const orders = await mockService.getMockOrders()
|
||||||
|
const found = orders.find((o: MockOrder): boolean => o.order_no === this.orderNo)
|
||||||
|
if (found != null) {
|
||||||
|
// 确保对象引用更新以触发响应式
|
||||||
|
this.order = { ...found } as MockOrder
|
||||||
|
}
|
||||||
|
// 确保数组引用更新
|
||||||
|
const tracking = await mockService.getMockTracking(this.orderNo)
|
||||||
|
this.statusHistory = [...tracking]
|
||||||
|
},
|
||||||
|
getStatusText(status: string): string {
|
||||||
|
return mockService.getStatusText(status)
|
||||||
|
},
|
||||||
|
getStatusIcon(status: string): string {
|
||||||
|
return mockService.getStatusIcon(status)
|
||||||
|
},
|
||||||
|
getStatusLabel(status: string): string {
|
||||||
|
const label = LogisticsConstants.STATUS_LABELS_SHORT[status] as string | null
|
||||||
|
return (label != null) ? label : '状态'
|
||||||
|
},
|
||||||
|
getLatestEventText(): string {
|
||||||
|
if (this.statusHistory.length > 0) {
|
||||||
|
return this.statusHistory[0].event_text
|
||||||
|
}
|
||||||
|
return '暂无物流信息'
|
||||||
|
},
|
||||||
|
shouldShowStatus(index: number): boolean {
|
||||||
|
if (index == 0) return true
|
||||||
|
const current = this.statusHistory[index].status_code
|
||||||
|
const prev = this.statusHistory[index - 1].status_code
|
||||||
|
// 如果当前状态与上一个状态不同,或者是状态变更点,则显示标签
|
||||||
|
return current != '' && current != prev
|
||||||
|
},
|
||||||
|
copyTrackingNo() {
|
||||||
|
if (!this.order.tracking_no || this.order.tracking_no === '') {
|
||||||
|
uni.showToast({ title: '暂无运单号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: this.order.tracking_no,
|
||||||
|
success: () => uni.showToast({ title: '单号已复制' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
callCourier() {
|
||||||
|
uni.makePhoneCall({ phoneNumber: '13800138000' })
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
if (this.order.status === 'DELIVERED') {
|
||||||
|
uni.showToast({ title: '已签收包裹无需刷新', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showLoading({ title: '同步中...' })
|
||||||
|
const success = await mockService.syncFromCloud(this.orderNo)
|
||||||
|
uni.hideLoading()
|
||||||
|
if (success) {
|
||||||
|
this.loadData()
|
||||||
|
uni.showToast({ title: '已同步最新动态' })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '暂无更多更新', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirmReceipt() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认取件',
|
||||||
|
content: '您是否已收到包裹?确认后订单将变更为已签收状态。',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
const ok = await mockService.confirmReceipt(this.orderNo)
|
||||||
|
uni.hideLoading()
|
||||||
|
if (ok) {
|
||||||
|
uni.showToast({ title: '取件成功' })
|
||||||
|
this.loadData()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
previewImage(url: string, urls: string[]) {
|
||||||
|
uni.previewImage({ current: url, urls: urls })
|
||||||
|
},
|
||||||
|
showSupport() {
|
||||||
|
uni.showToast({ title: '接入平台客服中...', icon: 'none' })
|
||||||
|
},
|
||||||
|
getStatusLabel(code: string): string {
|
||||||
|
return mockService.getStatusText(code)
|
||||||
|
},
|
||||||
|
splitText(text: string, statusCode: string): UTSJSONObject[] {
|
||||||
|
// 1. 先进行描述泛化(运输中脱敏)
|
||||||
|
const processedText = mockService.getDisplayMessage(text, statusCode)
|
||||||
|
|
||||||
|
const res = [] as UTSJSONObject[]
|
||||||
|
const regex = /(1[3-9]\d{9})/g
|
||||||
|
const parts = processedText.split(regex)
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i]
|
||||||
|
if (i % 2 === 1) {
|
||||||
|
// 消费者端:仅在派送中环节展示手机号且必须脱敏
|
||||||
|
if (statusCode === 'OUT_FOR_DELIVERY') {
|
||||||
|
const masked = part.substring(0, 3) + '****' + part.substring(7)
|
||||||
|
res.push({ text: masked, raw: part, isPhone: true } as UTSJSONObject)
|
||||||
|
} else {
|
||||||
|
res.push({ text: '[联系方式已隐藏]', raw: '', isPhone: false } as UTSJSONObject)
|
||||||
|
}
|
||||||
|
} else if (part.length > 0) {
|
||||||
|
res.push({ text: part, raw: '', isPhone: false } as UTSJSONObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
makeCall(phone: string) {
|
||||||
|
// 获取原始电话拨打
|
||||||
|
uni.makePhoneCall({ phoneNumber: phone })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 350rpx;
|
||||||
|
}
|
||||||
|
.status-header {
|
||||||
|
background: linear-gradient(135deg, #007AFF, #0056b3);
|
||||||
|
padding: 60rpx 40rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.back-link-white {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.status-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30rpx;
|
||||||
|
}
|
||||||
|
.status-icon {
|
||||||
|
font-size: 80rpx;
|
||||||
|
}
|
||||||
|
.status-title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.status-desc-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.status-desc-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
opacity: 0.9;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.status-desc-text.highlight {
|
||||||
|
color: #FFD700;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.event-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.event-title-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.event-title-text.active {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.event-title-text.highlight {
|
||||||
|
color: #007AFF;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.header-call-btn {
|
||||||
|
/* Lines 258-267 omitted */
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
}
|
||||||
|
.carrier-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.carrier-logo {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.carrier-detail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.carrier-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.waybill-no {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
background-color: #eef2f7;
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
.courier-box {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
padding-top: 30rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.courier-tip {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #27ae60;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.call-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
background-color: #007AFF;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
padding: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.axis {
|
||||||
|
width: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 15rpx;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
width: 2rpx;
|
||||||
|
flex: 1;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
background-color: #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
.dot.active {
|
||||||
|
background-color: #007AFF;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
box-shadow: 0 0 8rpx rgba(0,122,255,0.3);
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.event-header {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
.status-time-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
.status-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.status-label.active {
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
.event-time.margin-left {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
/* 当状态标签隐藏时,时间不需要左间距,但为了对齐建议保持一定偏离或者在没有标签时直接显示 */
|
||||||
|
.event-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.event-title.active {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.event-time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.event-time.active {
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
.evidence-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.evidence-img {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
.bottom-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 16rpx 30rpx 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.08);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.action-item {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 26rpx;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1rpx solid #e2e8f0;
|
||||||
|
margin: 0;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
}
|
||||||
|
.action-item.primary {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #409eff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
239
pages/mall/delivery/test/consumer-order-list.uvue
Normal file
239
pages/mall/delivery/test/consumer-order-list.uvue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<view class="header-main">
|
||||||
|
<text class="title">我的快递</text>
|
||||||
|
<text class="count">共 {{ orders.length }} 个包裹</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab-item" :class="{active: currentTab === 'all'}" @click="currentTab = 'all'">全部</view>
|
||||||
|
<view class="tab-item" :class="{active: currentTab === 'active'}" @click="currentTab = 'active'">进行中</view>
|
||||||
|
<view class="tab-item" :class="{active: currentTab === 'completed'}" @click="currentTab = 'completed'">已签收</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="order-list" scroll-y="true">
|
||||||
|
<view v-for="(order, index) in filteredOrders" :key="order.order_no" class="order-card" @click="goDetail(order.order_no)">
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="carrier-tag">{{ order.carrier }}</view>
|
||||||
|
<text class="status-text" :class="order.status">{{ getStatusText(order.status) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-body">
|
||||||
|
<view class="pkg-icon">📦</view>
|
||||||
|
<view class="pkg-info">
|
||||||
|
<text class="latest-msg">{{ getDisplayMessage(order.current_status_text || '', order.status) }}</text>
|
||||||
|
<text class="order-meta">订单号: {{ order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="arrow">></text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-footer">
|
||||||
|
<text class="time">下单时间: {{ order.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="filteredOrders.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">📂</text>
|
||||||
|
<text class="empty-text">暂无相关包裹信息</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import { mockService, MockOrder } from './mock-service.uts'
|
||||||
|
import { getCurrentUser } from '@/utils/store.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentTab: 'all',
|
||||||
|
orders: [] as MockOrder[]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadData()
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredOrders(): MockOrder[] {
|
||||||
|
if (this.currentTab === 'all') {
|
||||||
|
return this.orders
|
||||||
|
}
|
||||||
|
if (this.currentTab === 'completed') {
|
||||||
|
return this.orders.filter((o: MockOrder): boolean => o.status === 'DELIVERED')
|
||||||
|
} else {
|
||||||
|
return this.orders.filter((o: MockOrder): boolean => o.status !== 'DELIVERED')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async loadData() {
|
||||||
|
// Ensure user mapping is ready before querying orders
|
||||||
|
try {
|
||||||
|
await getCurrentUser()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('getCurrentUser failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await mockService.getMockOrders()
|
||||||
|
this.orders = [...data]
|
||||||
|
},
|
||||||
|
getStatusText(status: string): string {
|
||||||
|
return mockService.getStatusText(status)
|
||||||
|
},
|
||||||
|
getDisplayMessage(text: string, status: string): string {
|
||||||
|
return mockService.getDisplayMessage(text, status)
|
||||||
|
},
|
||||||
|
goDetail(orderNo: string) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/mall/delivery/test/consumer-logistics-detail?order_no=${orderNo}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
background-color: #f7f8fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 30rpx;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #007AFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.header-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
gap: 40rpx;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #666;
|
||||||
|
padding-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
.tab-item.active {
|
||||||
|
color: #007AFF;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 4rpx solid #007AFF;
|
||||||
|
}
|
||||||
|
.order-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.carrier-tag {
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
color: #007AFF;
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
.status-text.DELIVERED { color: #52c41a; }
|
||||||
|
.status-text.SHIPPED { color: #007AFF; }
|
||||||
|
.status-text.OUT_FOR_DELIVERY { color: #faad14; }
|
||||||
|
.status-text.EXCEPTION { color: #ff4d4f; }
|
||||||
|
.status-text.PENDING { color: #999; }
|
||||||
|
.status-text.DELIVERED { color: #999; }
|
||||||
|
.status-text.OUT_FOR_DELIVERY { color: #27ae60; }
|
||||||
|
.status-text.SHIPPED { color: #007AFF; }
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 10rpx 0;
|
||||||
|
}
|
||||||
|
.pkg-icon {
|
||||||
|
font-size: 50rpx;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
width: 90rpx;
|
||||||
|
height: 90rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.pkg-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.latest-msg {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.order-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f2f2f2;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
padding-top: 200rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 100rpx;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.empty-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
133
pages/mall/delivery/test/index.uvue
Normal file
133
pages/mall/delivery/test/index.uvue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">配送模块测试页面索引</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">商家后台 (Merchant)</text>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/merchant-order-list')">
|
||||||
|
<text class="nav-name">订单列表与发货</text>
|
||||||
|
<text class="nav-desc">查看待发货订单、绑定运单号</text>
|
||||||
|
</view>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/merchant-order-detail?order_no=ORD20260205001')">
|
||||||
|
<text class="nav-name">订单详情与物流轨迹</text>
|
||||||
|
<text class="nav-desc">物流时间线展示、签收凭证预览</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">平台后台 (Platform/Ops)</text>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-tracking-query')">
|
||||||
|
<text class="nav-name">运单/轨迹查询 (排障)</text>
|
||||||
|
<text class="nav-desc">按运单号查询、查看 Raw Payload、Mock 控制</text>
|
||||||
|
</view>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-webhook-logs')">
|
||||||
|
<text class="nav-name">Webhook 接收日志</text>
|
||||||
|
<text class="nav-desc">核对回调、验签状态、入库结果</text>
|
||||||
|
</view>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-config-center')">
|
||||||
|
<text class="nav-name">对接配置中心</text>
|
||||||
|
<text class="nav-desc">承运商管理、Mock 服务开关、密钥配置</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">消费者端 (Consumer/APP)</text>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/consumer-order-list')">
|
||||||
|
<text class="nav-name">我的快递 (物流列表)</text>
|
||||||
|
<text class="nav-desc">查看多个包裹状态、实时动态摘要</text>
|
||||||
|
</view>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/consumer-logistics-detail?order_no=ORD20260205002')">
|
||||||
|
<text class="nav-name">用户物流详情页</text>
|
||||||
|
<text class="nav-desc">单包裹高保真轨迹、联系派送员</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">模拟与开发工具 (Tools)</text>
|
||||||
|
<view class="nav-card" @click="goto('/pages/mall/delivery/test/api-simulator')">
|
||||||
|
<text class="nav-name">第三方 API 推送模拟</text>
|
||||||
|
<text class="nav-desc">手动构造 Webhook Payload 发送给系统</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
goto(url: string) {
|
||||||
|
uni.navigateTo({ url: url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
padding: 30rpx;
|
||||||
|
background-color: #f4f7f9;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 50rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #007AFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #95a5a6;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding-left: 10rpx;
|
||||||
|
}
|
||||||
|
.nav-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.nav-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #34495e;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #95a5a6;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 100rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
294
pages/mall/delivery/test/merchant-order-detail.uvue
Normal file
294
pages/mall/delivery/test/merchant-order-detail.uvue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">商家订单详情</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态卡片 -->
|
||||||
|
<view class="status-card">
|
||||||
|
<text class="status-title">{{ getStatusText(order.status) }}</text>
|
||||||
|
<text v-if="order.tracking_no" class="status-desc">{{ order.carrier }}: {{ order.tracking_no }}</text>
|
||||||
|
<text v-else class="status-desc">待商家发货</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 收货人信息 -->
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="section-header">收货信息</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">收货人</text>
|
||||||
|
<text class="value">{{ order.receiver_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">手机号</text>
|
||||||
|
<text class="value">{{ order.receiver_masked_phone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">地址</text>
|
||||||
|
<text class="value">{{ order.address }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 物流追踪 -->
|
||||||
|
<view v-if="order.tracking_no" class="section-card">
|
||||||
|
<view class="section-header">物流追踪</view>
|
||||||
|
<view class="timeline">
|
||||||
|
<view v-for="(event, index) in statusHistory" :key="index" class="timeline-item">
|
||||||
|
<view class="timeline-line" v-if="index !== statusHistory.length - 1"></view>
|
||||||
|
<view class="timeline-dot" :class="{active: index === 0}"></view>
|
||||||
|
|
||||||
|
<view class="timeline-content">
|
||||||
|
<view class="event-title-row">
|
||||||
|
<text v-for="(seg, i) in getProcessedText(event.event_text, event.status_code)" :key="i" class="event-text" :class="{active: index === 0}">{{ seg }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="event-time">{{ event.event_time }}</text>
|
||||||
|
|
||||||
|
<!-- 证据/签收图片 -->
|
||||||
|
<view v-if="event.evidence_urls.length > 0" class="evidence-grid">
|
||||||
|
<image v-for="(img, idx) in event.evidence_urls" :key="idx" :src="img" class="evidence-img" mode="aspectFill" @click="previewImage(img, event.evidence_urls)" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="statusHistory.length === 0" class="empty-timeline">
|
||||||
|
<text>暂无物流动态</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="timeline-footer">
|
||||||
|
<text class="sync-time">最后更新: {{ order.last_synced_at || '尚未同步' }}</text>
|
||||||
|
<text class="refresh-link" @click="refreshLogistics">手动刷新</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 订单明细 -->
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="section-header">订单详情</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">订单号</text>
|
||||||
|
<text class="value">{{ order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">下单时间</text>
|
||||||
|
<text class="value">{{ order.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">支付金额</text>
|
||||||
|
<text class="value">¥{{ order.amount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import { mockService, MockOrder, MockTrackingEvent } from './mock-service.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orderNo: '',
|
||||||
|
order: {
|
||||||
|
order_no: '',
|
||||||
|
status: 'SHIPPED',
|
||||||
|
carrier: 'YUNDA',
|
||||||
|
tracking_no: 'YD987654321',
|
||||||
|
receiver_name: '张三',
|
||||||
|
receiver_masked_phone: '138****8000',
|
||||||
|
address: '北京市朝阳区某某街道100号',
|
||||||
|
amount: '299.00',
|
||||||
|
created_at: '2026-02-05 10:00',
|
||||||
|
last_synced_at: '2026-02-05 14:35'
|
||||||
|
} as MockOrder,
|
||||||
|
statusHistory: [] as MockTrackingEvent[]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options: any) {
|
||||||
|
this.orderNo = (options['order_no'] != null) ? options['order_no'] as string : ''
|
||||||
|
this.loadData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async loadData() {
|
||||||
|
// 获取匹配的订单基本信息
|
||||||
|
const allOrders = await mockService.getMockOrders()
|
||||||
|
const found = allOrders.find((o : MockOrder) : boolean => o.order_no === this.orderNo)
|
||||||
|
if (found != null) {
|
||||||
|
this.order = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取轨迹
|
||||||
|
this.statusHistory = await mockService.getMockTracking(this.orderNo)
|
||||||
|
},
|
||||||
|
getStatusText(status: string) : string {
|
||||||
|
return mockService.getStatusText(status)
|
||||||
|
},
|
||||||
|
async refreshLogistics() {
|
||||||
|
if (this.order.status === 'DELIVERED') {
|
||||||
|
uni.showToast({ title: '订单已完成', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showLoading({ title: '查询中...' })
|
||||||
|
const success = await mockService.syncFromCloud(this.orderNo)
|
||||||
|
uni.hideLoading()
|
||||||
|
if (success) {
|
||||||
|
this.loadData()
|
||||||
|
uni.showToast({ title: '已获取最新轨迹' })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '数据已是最新', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
previewImage(url: string, urls: string[]) {
|
||||||
|
uni.previewImage({
|
||||||
|
current: url,
|
||||||
|
urls: urls
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getProcessedText(text: string, status: string): string[] {
|
||||||
|
const displayMsg = mockService.getDisplayMessage(text, status)
|
||||||
|
return this.filterPhone(displayMsg)
|
||||||
|
},
|
||||||
|
filterPhone(text: string): string[] {
|
||||||
|
// 依照需求文档 1. 敏感信息展示差异:商家端不展示手机号
|
||||||
|
// 直接移除所有手机号
|
||||||
|
return [text.replace(/(1[3-9]\d{9})/g, '')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 20rpx;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding: 20rpx 10rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #007AFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
background: linear-gradient(to right, #007AFF, #00C6FF);
|
||||||
|
padding: 40rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.status-title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
.status-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.section-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
border-left: 8rpx solid #007AFF;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 40rpx;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
.timeline-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 6rpx;
|
||||||
|
top: 10rpx;
|
||||||
|
bottom: -10rpx;
|
||||||
|
width: 2rpx;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 10rpx;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ccc;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.timeline-dot.active {
|
||||||
|
background-color: #007AFF;
|
||||||
|
box-shadow: 0 0 10rpx rgba(0,122,255,0.5);
|
||||||
|
}
|
||||||
|
.event-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.event-text.active {
|
||||||
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.event-time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.evidence-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.evidence-img {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.timeline-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #eee;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.sync-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.refresh-link {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
416
pages/mall/delivery/test/merchant-order-list.uvue
Normal file
416
pages/mall/delivery/test/merchant-order-list.uvue
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">商家发货管理</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<view class="filter-bar">
|
||||||
|
<view class="filter-item" :class="{active: currentFilter === 'all'}" @click="setFilter('all')">全部</view>
|
||||||
|
<view class="filter-item" :class="{active: currentFilter === 'pending'}" @click="setFilter('pending')">待发货</view>
|
||||||
|
<view class="filter-item" :class="{active: currentFilter === 'shipped'}" @click="setFilter('shipped')">已发货</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="order-list" scroll-y="true">
|
||||||
|
<view v-for="(order, index) in filteredOrders" :key="order.order_no" class="order-card">
|
||||||
|
<view class="order-header">
|
||||||
|
<text class="order-no">订单号: {{ order.order_no }}</text>
|
||||||
|
<text class="order-status">{{ getStatusText(order.status) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-body">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">下单时间:</text>
|
||||||
|
<text class="value">{{ order.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">收件人:</text>
|
||||||
|
<text class="value">{{ order.receiver_name }} {{ order.receiver_masked_phone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">运单号:</text>
|
||||||
|
<text class="value">{{ order.tracking_no ? order.tracking_no + ' (' + order.carrier + ')' : '暂无运单号' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="order-footer">
|
||||||
|
<button v-if="order.status === 'PENDING' || order.status === 'ORDER_PLACED'" class="btn-primary" @click="openShipModal(order)">去发货</button>
|
||||||
|
<button class="btn-secondary" @click="viewDetail(order)">详情</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="filteredOrders.length === 0" class="empty-state">
|
||||||
|
<text>暂无订单</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 发货弹窗 -->
|
||||||
|
<view v-if="showShipModal" class="modal-mask">
|
||||||
|
<view class="modal-content">
|
||||||
|
<view class="modal-header">
|
||||||
|
<view class="title-box">
|
||||||
|
<text class="modal-title">订单发货</text>
|
||||||
|
<text v-if="selectedOrder != null" class="modal-order-no">单号: {{ selectedOrder!.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="close-btn" @click="showShipModal = false">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="modal-body">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">选择承运商</text>
|
||||||
|
<picker :range="carriers" range-key="label" @change="onCarrierChange">
|
||||||
|
<view class="picker-val">{{ currentCarrier || '请选择' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">运单号</text>
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<input v-model="trackingNo" placeholder="请输入或扫码录入" class="input" />
|
||||||
|
<view class="scan-btn" @click="scanCode">
|
||||||
|
<text class="scan-icon">📷</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="modal-footer">
|
||||||
|
<button class="btn-cancel" @click="showShipModal = false">取消</button>
|
||||||
|
<button class="btn-confirm" @click="confirmShip">确认发货</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
|
import { mockService, MockOrder, LogisticsConstants } from './mock-service.uts'
|
||||||
|
import { getCurrentUser, getCurrentUserId } from '@/utils/store.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentFilter: 'all',
|
||||||
|
orders: [] as MockOrder[],
|
||||||
|
showShipModal: false,
|
||||||
|
selectedOrder: null as MockOrder | null,
|
||||||
|
carriers: LogisticsConstants.CARRIERS,
|
||||||
|
currentCarrier: '顺丰速运',
|
||||||
|
trackingNo: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadData()
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredOrders() : MockOrder[] {
|
||||||
|
const list = this.orders
|
||||||
|
if (this.currentFilter === 'all') return list
|
||||||
|
|
||||||
|
// 待发货:包含已下单和待发货状态
|
||||||
|
if (this.currentFilter === 'pending') {
|
||||||
|
return list.filter((o : MockOrder) : boolean =>
|
||||||
|
o.status === 'PENDING' || o.status === 'ORDER_PLACED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已发货:只要已经有了运单号或状态已转为发货后的
|
||||||
|
if (this.currentFilter === 'shipped') {
|
||||||
|
const shippedStates = ['SHIPPED', 'IN_TRANSIT', 'OUT_FOR_DELIVERY', 'READY_FOR_PICKUP', 'DELIVERED', 'EXCEPTION', 'RETURNED']
|
||||||
|
return list.filter((o : MockOrder) : boolean => shippedStates.includes(o.status))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async loadData() {
|
||||||
|
// Ensure user mapping (auth -> ak_users) is ready before querying
|
||||||
|
try {
|
||||||
|
await getCurrentUser()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('getCurrentUser failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取运单并按 merchant_id 过滤
|
||||||
|
const data = await mockService.getMockOrders()
|
||||||
|
const curId = getCurrentUserId()
|
||||||
|
if (curId && curId !== '' && curId !== 'admin') {
|
||||||
|
this.orders = [...data].filter((o: any) => (o.merchant_id === curId))
|
||||||
|
} else {
|
||||||
|
this.orders = [...data]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setFilter(filter: string) {
|
||||||
|
this.currentFilter = filter
|
||||||
|
},
|
||||||
|
getStatusText(status: string) : string {
|
||||||
|
return mockService.getStatusText(status)
|
||||||
|
},
|
||||||
|
openShipModal(order: MockOrder) {
|
||||||
|
this.selectedOrder = order
|
||||||
|
this.currentCarrier = '顺丰速运'
|
||||||
|
this.trackingNo = ''
|
||||||
|
this.showShipModal = true
|
||||||
|
},
|
||||||
|
onCarrierChange(e: any) {
|
||||||
|
const index = e.detail.value as number
|
||||||
|
this.currentCarrier = this.carriers[index]['value'] as string
|
||||||
|
},
|
||||||
|
async confirmShip() {
|
||||||
|
if (!this.trackingNo) {
|
||||||
|
uni.showToast({ title: '请输入运单号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showLoading({ title: '提交中...' })
|
||||||
|
|
||||||
|
const orderNo = this.selectedOrder?.order_no
|
||||||
|
if (orderNo != null) {
|
||||||
|
// 1. 同步修改 数据库 中的数据
|
||||||
|
const success = await mockService.bindShipment(orderNo, this.currentCarrier, this.trackingNo)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 2. 直接修改本地选中的订单对象状态,确保 Vue 响应式立即触发
|
||||||
|
if (this.selectedOrder != null) {
|
||||||
|
const target = this.selectedOrder!
|
||||||
|
target.status = 'SHIPPED'
|
||||||
|
target.carrier = this.currentCarrier
|
||||||
|
target.tracking_no = this.trackingNo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 重新加载数据(从数据库刷新)
|
||||||
|
await this.loadData()
|
||||||
|
|
||||||
|
uni.hideLoading()
|
||||||
|
this.showShipModal = false
|
||||||
|
uni.showToast({ title: '发货成功' })
|
||||||
|
} else {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '发货失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
viewDetail(order: MockOrder) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/mall/delivery/test/merchant-order-detail?order_no=${order.order_no}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scanCode() {
|
||||||
|
uni.scanCode({
|
||||||
|
success: (res) => {
|
||||||
|
this.trackingNo = res.result
|
||||||
|
},
|
||||||
|
fail: (_) => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '该功能尚未完成',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.order-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding: 30rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #007AFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.filter-item.active {
|
||||||
|
color: #007AFF;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 4rpx solid #007AFF;
|
||||||
|
}
|
||||||
|
.order-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.order-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1rpx solid #eee;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.order-no {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.order-status {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
width: 140rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.order-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #007AFF;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 10rpx 30rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 10rpx 30rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
}
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 80%;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 40rpx;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.title-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.modal-order-no {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 10rpx;
|
||||||
|
}
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.picker-val {
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
padding: 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
padding: 16rpx;
|
||||||
|
padding-right: 80rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.scan-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.scan-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
.btn-confirm {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #007AFF;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #eee;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
673
pages/mall/delivery/test/mock-service.uts
Normal file
673
pages/mall/delivery/test/mock-service.uts
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
|
import { getCurrentUserId } from '@/utils/store.uts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配送模块数据服务 (Express Service)
|
||||||
|
* 依据: pages/mall/delivery/doc/需求文档/
|
||||||
|
* 功能: 对接 Supabase 生产表 (platform_express_*)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MockOrder {
|
||||||
|
id?: string
|
||||||
|
order_no: string
|
||||||
|
user_id?: string
|
||||||
|
merchant_id?: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
receiver_name: string
|
||||||
|
receiver_masked_phone: string
|
||||||
|
address: string
|
||||||
|
amount: string
|
||||||
|
carrier: string
|
||||||
|
tracking_no: string
|
||||||
|
last_synced_at?: string
|
||||||
|
current_status_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockTrackingEvent {
|
||||||
|
event_id: string
|
||||||
|
event_time: string
|
||||||
|
event_code: string
|
||||||
|
event_text: string
|
||||||
|
status_code: string
|
||||||
|
node_name?: string
|
||||||
|
location?: string
|
||||||
|
evidence_urls: string[]
|
||||||
|
raw_payload?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockWebhookLog {
|
||||||
|
time: string
|
||||||
|
carrier: string
|
||||||
|
tracking_no: string
|
||||||
|
event_code: string
|
||||||
|
success: boolean
|
||||||
|
result_text: string
|
||||||
|
payload: UTSJSONObject
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 集中管理物流常量与映射
|
||||||
|
*/
|
||||||
|
export const LogisticsConstants = {
|
||||||
|
// 8 大标准配送状态映射
|
||||||
|
STATUS_MAP: {
|
||||||
|
'ORDER_PLACED': '已下单',
|
||||||
|
'SHIPPED': '已发货',
|
||||||
|
'PENDING': '待发货',
|
||||||
|
'IN_TRANSIT': '运输中',
|
||||||
|
'OUT_FOR_DELIVERY': '派送中',
|
||||||
|
'READY_FOR_PICKUP': '待取件',
|
||||||
|
'DELIVERED': '已签收',
|
||||||
|
'EXCEPTION': '异常',
|
||||||
|
'RETURNED': '退回/退件'
|
||||||
|
} as UTSJSONObject,
|
||||||
|
|
||||||
|
// 状态对应图标
|
||||||
|
STATUS_ICONS: {
|
||||||
|
'ORDER_PLACED': '📦',
|
||||||
|
'SHIPPED': '🚚',
|
||||||
|
'IN_TRANSIT': '🛤️',
|
||||||
|
'OUT_FOR_DELIVERY': '🛵',
|
||||||
|
'READY_FOR_PICKUP': '🏪',
|
||||||
|
'DELIVERED': '✅',
|
||||||
|
'EXCEPTION': '❗',
|
||||||
|
'RETURNED': '🔄'
|
||||||
|
} as UTSJSONObject,
|
||||||
|
|
||||||
|
// 时间线简短标签
|
||||||
|
STATUS_LABELS_SHORT: {
|
||||||
|
'ORDER_PLACED': '下单',
|
||||||
|
'SHIPPED': '出库',
|
||||||
|
'IN_TRANSIT': '运输',
|
||||||
|
'OUT_FOR_DELIVERY': '派送',
|
||||||
|
'READY_FOR_PICKUP': '待取',
|
||||||
|
'DELIVERED': '完成',
|
||||||
|
'EXCEPTION': '异常',
|
||||||
|
'RETURNED': '退回'
|
||||||
|
} as UTSJSONObject,
|
||||||
|
|
||||||
|
// 常用快递公司列表
|
||||||
|
CARRIERS: [
|
||||||
|
{ label: '顺丰速运', value: '顺丰速运' },
|
||||||
|
{ label: '圆通速递', value: '圆通速递' },
|
||||||
|
{ label: '中通快递', value: '中通快递' },
|
||||||
|
{ label: '申通快递', value: '申通快递' },
|
||||||
|
{ label: '韵达快递', value: '韵达快递' },
|
||||||
|
{ label: '京东快递', value: '京东快递' }
|
||||||
|
] as UTSJSONObject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockService {
|
||||||
|
// 全局 Mock 配置项
|
||||||
|
public isTestMode: boolean = true
|
||||||
|
public autoPush: boolean = true
|
||||||
|
public mockUrl: string = 'http://192.168.1.100:3000/mock/v1'
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async getMockOrders(): Promise<MockOrder[]> {
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
console.log('DEBUG: currentUserId =', userId)
|
||||||
|
|
||||||
|
// 方案 1:直接通过运单表关联订单表的 user_id 过滤
|
||||||
|
// 注意:确保 ml_orders!inner(*) 强制关联,否则 eq 会失效
|
||||||
|
const query = supa.from('platform_express_waybills')
|
||||||
|
.select('*, ml_orders!inner(*)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (userId != '' && userId != 'admin') {
|
||||||
|
console.log('DEBUG: filtering by ml_orders.user_id =', userId)
|
||||||
|
query.eq('ml_orders.user_id', userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: d1, error: e1 } = await query.execute()
|
||||||
|
|
||||||
|
// 如果请求本身报错,先记录并尝试降级策略
|
||||||
|
if (e1 != null) {
|
||||||
|
console.error('DEBUG: primary query error =', e1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化返回的数据为数组(Supabase 客户端在某些情况下可能返回包装对象)
|
||||||
|
let resultArray: Array<UTSJSONObject> | null = null
|
||||||
|
if (Array.isArray(d1)) {
|
||||||
|
resultArray = d1 as Array<UTSJSONObject>
|
||||||
|
} else if (d1 != null && (d1 as any).data && Array.isArray((d1 as any).data)) {
|
||||||
|
resultArray = (d1 as any).data as Array<UTSJSONObject>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案 2(保底):如果方案 1 没查到数据,可能是因为某些运单没有关联订单,或者 !inner 过滤太严格
|
||||||
|
if (resultArray == null || resultArray.length === 0) {
|
||||||
|
console.log('DEBUG: first query empty or error, trying fallback...')
|
||||||
|
const query2 = supa.from('platform_express_waybills')
|
||||||
|
.select('*, ml_orders(*)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
const { data: d2, error: e2 } = await query2.execute()
|
||||||
|
if (e2 != null) {
|
||||||
|
console.error('DEBUG: fallback query error =', e2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allData = Array.isArray(d2) ? (d2 as Array<UTSJSONObject>) : ((d2 && (d2 as any).data && Array.isArray((d2 as any).data)) ? (d2 as any).data : null)
|
||||||
|
|
||||||
|
if (allData != null) {
|
||||||
|
// 在内存中过滤归属于当前用户的订单(用于数据还没同步完全的情况)
|
||||||
|
const filtered = allData.filter((item: UTSJSONObject): boolean => {
|
||||||
|
if (userId == '' || userId == 'admin') return true
|
||||||
|
|
||||||
|
let ml: UTSJSONObject | null = null
|
||||||
|
const mlData = item['ml_orders']
|
||||||
|
if (mlData instanceof Array && (mlData as Array<any>).length > 0) {
|
||||||
|
ml = (mlData as Array<UTSJSONObject>)[0]
|
||||||
|
} else if (!(mlData instanceof Array)) {
|
||||||
|
ml = mlData as UTSJSONObject
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ml != null && (ml as any).user_id == userId
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('DEBUG: filtered in memory length =', filtered.length)
|
||||||
|
return this.mapDataToOrders(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DEBUG: query result data length =', resultArray ? resultArray.length : 0)
|
||||||
|
return this.mapDataToOrders(resultArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽离数据映射逻辑
|
||||||
|
private mapDataToOrders(data: Array<UTSJSONObject> | null): MockOrder[] {
|
||||||
|
if (data == null) return []
|
||||||
|
return data.map((item: UTSJSONObject): MockOrder => {
|
||||||
|
let ml: UTSJSONObject | null = null
|
||||||
|
const mlData = item['ml_orders']
|
||||||
|
if (mlData != null) {
|
||||||
|
if (mlData instanceof Array && (mlData as Array<any>).length > 0) {
|
||||||
|
ml = (mlData as Array<UTSJSONObject>)[0]
|
||||||
|
} else if (!(mlData instanceof Array)) {
|
||||||
|
ml = mlData as UTSJSONObject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item['id'] as string,
|
||||||
|
order_no: item['order_no'] as string || (ml != null ? ml['order_no'] as string : ''),
|
||||||
|
status: item['current_status_code'] as string,
|
||||||
|
created_at: this.formatDBTime(item['created_at'] as string),
|
||||||
|
receiver_name: (ml != null ? ml['receiver_name'] as string : '测试用户'),
|
||||||
|
receiver_masked_phone: this.maskPhone(ml != null ? ml['receiver_phone'] as string : '138****0000'),
|
||||||
|
address: (ml != null ? ml['address'] as string : '系统模拟地址'),
|
||||||
|
amount: (ml != null ? ml['amount'] as string : '0.00'),
|
||||||
|
carrier: item['carrier'] as string,
|
||||||
|
tracking_no: item['tracking_no'] as string,
|
||||||
|
merchant_id: (ml != null ? ml['merchant_id'] as string : ''),
|
||||||
|
last_synced_at: this.formatDBTime(item['last_synced_at'] as string),
|
||||||
|
current_status_text: item['current_status_text'] as string
|
||||||
|
} as MockOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMockTracking(id: string): Promise<MockTrackingEvent[]> {
|
||||||
|
if (!id || id == '') return []
|
||||||
|
|
||||||
|
// 1. 穿透式查询:先寻找运单 ID
|
||||||
|
let waybillId: string | null = null
|
||||||
|
let trackingNo: string | null = null
|
||||||
|
|
||||||
|
// 尝试按订单号找
|
||||||
|
const { data: b1 } = await supa.from('platform_express_waybills').select('id,tracking_no').eq('order_no', id).execute()
|
||||||
|
if (b1 != null && (b1 as Array<any>).length > 0) {
|
||||||
|
const row = (b1 as Array<UTSJSONObject>)[0]
|
||||||
|
waybillId = row['id'] as string
|
||||||
|
trackingNo = row['tracking_no'] as string
|
||||||
|
} else {
|
||||||
|
// 尝试按运单号找
|
||||||
|
const { data: b2 } = await supa.from('platform_express_waybills').select('id,tracking_no').eq('tracking_no', id).execute()
|
||||||
|
if (b2 != null && (b2 as Array<any>).length > 0) {
|
||||||
|
const row = (b2 as Array<UTSJSONObject>)[0]
|
||||||
|
waybillId = row['id'] as string
|
||||||
|
trackingNo = row['tracking_no'] as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waybillId != null) {
|
||||||
|
// 2. 查该运单聚合的所有轨迹
|
||||||
|
// 同样为了稳定,分两次查或使用简单的 or (如果 or 有效)
|
||||||
|
let allEvents = [] as MockTrackingEvent[]
|
||||||
|
|
||||||
|
const { data: events, error } = await supa.from('platform_express_tracking_events')
|
||||||
|
.select('*')
|
||||||
|
.eq('waybill_id', waybillId!)
|
||||||
|
.order('event_time', { ascending: false })
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (events != null) {
|
||||||
|
allEvents = this.mapToEvents(events as Array<UTSJSONObject>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果按 UUID 没查到,尝试按单号补查
|
||||||
|
if (allEvents.length == 0 && trackingNo != null) {
|
||||||
|
const { data: events2 } = await supa.from('platform_express_tracking_events')
|
||||||
|
.select('*')
|
||||||
|
.eq('tracking_no', trackingNo!)
|
||||||
|
.order('event_time', { ascending: false })
|
||||||
|
.execute()
|
||||||
|
if (events2 != null) {
|
||||||
|
allEvents = this.mapToEvents(events2 as Array<UTSJSONObject>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最后的保底:直接尝试在轨迹表中匹配 (单号直接搜索)
|
||||||
|
const { data: directEvents } = await supa.from('platform_express_tracking_events')
|
||||||
|
.select('*')
|
||||||
|
.eq('tracking_no', id)
|
||||||
|
.order('event_time', { ascending: false })
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (directEvents != null && (directEvents as Array<any>).length > 0) {
|
||||||
|
return this.mapToEvents(directEvents as Array<UTSJSONObject>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理/调试用:获取所有运单(不做用户过滤)
|
||||||
|
*/
|
||||||
|
async getAllOrders(): Promise<MockOrder[]> {
|
||||||
|
const { data, error } = await supa.from('platform_express_waybills')
|
||||||
|
.select('*, ml_orders(*)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
console.error('Fetch all orders error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = data as Array<UTSJSONObject>
|
||||||
|
return this.mapDataToOrders(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟各种物流场景 (生成生产测试数据)
|
||||||
|
*/
|
||||||
|
async runScenario(trackingNo: string, type: string) {
|
||||||
|
// 查找运单 (使用通用的单号查询逻辑)
|
||||||
|
const { data: waybills } = await supa.from('platform_express_waybills')
|
||||||
|
.select('id,carrier,tracking_no')
|
||||||
|
.eq('tracking_no', trackingNo)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (waybills == null || (waybills as Array<any>).length === 0) {
|
||||||
|
console.error('Scenario Error: Waybill not found', trackingNo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const waybill = (waybills as Array<UTSJSONObject>)[0]
|
||||||
|
const waybillId = waybill['id'] as string
|
||||||
|
const carrier = waybill['carrier'] as string
|
||||||
|
|
||||||
|
if (waybillId == null || waybillId == "") {
|
||||||
|
console.error('Scenario Error: Waybill ID is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟不同步长的数据注入
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const events = [] as any[]
|
||||||
|
|
||||||
|
if (type === 'full') {
|
||||||
|
events.push({ waybill_id: waybillId, carrier, tracking_no: trackingNo, event_time: now, event_code: 'SIGNED', event_text: '用户已签收', status_code: 'DELIVERED', dedupe_key: 'SCENARIO_SIG_'+Date.now() })
|
||||||
|
events.push({ waybill_id: waybillId, carrier, tracking_no: trackingNo, event_time: now, event_code: 'SENT', event_text: '派送中', status_code: 'OUT_FOR_DELIVERY', dedupe_key: 'SCENARIO_SEN_'+Date.now() })
|
||||||
|
} else if (type === 'standard') {
|
||||||
|
events.push({ waybill_id: waybillId, carrier, tracking_no: trackingNo, event_time: now, event_code: 'ARRIVED_HUB', event_text: '快件已到达中转站', status_code: 'IN_TRANSIT', dedupe_key: 'SCENARIO_HUB_'+Date.now() })
|
||||||
|
} else if (type === 'exception') {
|
||||||
|
events.push({ waybill_id: waybillId, carrier, tracking_no: trackingNo, event_time: now, event_code: 'FAILED', event_text: '包裹由于地址不详正在退回', status_code: 'EXCEPTION', dedupe_key: 'SCENARIO_ERR_'+Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
// 1. 插入轨迹节点
|
||||||
|
await supa.from('platform_express_tracking_events').insert(events).execute()
|
||||||
|
|
||||||
|
// 2. 同步更新主表状态 (以最新一个节点为准)
|
||||||
|
const latest = events[0] as UTSJSONObject
|
||||||
|
await supa.from('platform_express_waybills').update({
|
||||||
|
current_status_code: latest['status_code'] as string,
|
||||||
|
current_status_text: latest['event_text'] as string,
|
||||||
|
last_synced_at: now
|
||||||
|
}).eq('id', waybillId).execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToEvents(list: Array<UTSJSONObject>): MockTrackingEvent[] {
|
||||||
|
return list.map((e: UTSJSONObject): MockTrackingEvent => {
|
||||||
|
// 解析 evidence_urls (JSONB)
|
||||||
|
let urls = [] as string[]
|
||||||
|
const rawUrls = e['evidence_urls']
|
||||||
|
if (rawUrls instanceof Array) {
|
||||||
|
urls = rawUrls as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event_id: e['id'] as string,
|
||||||
|
event_time: this.formatDBTime(e['event_time'] as string),
|
||||||
|
event_code: e['event_code'] as string || '',
|
||||||
|
event_text: e['event_text'] as string || '',
|
||||||
|
status_code: e['status_code'] as string || '',
|
||||||
|
node_name: e['node_name'] as string || '',
|
||||||
|
location: e['location'] as string || '',
|
||||||
|
evidence_urls: urls,
|
||||||
|
raw_payload: JSON.stringify(e['raw_payload'] || {})
|
||||||
|
} as MockTrackingEvent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定发货逻辑:写库
|
||||||
|
async bindShipment(orderNo: string, carrier: string, trackingNo: string): Promise<boolean> {
|
||||||
|
// 1. 先查找订单主体
|
||||||
|
const { data: orderData } = await supa.from('ml_orders').select('id,order_no').eq('order_no', orderNo).single().execute()
|
||||||
|
const orderId = (orderData != null) ? orderData['id'] as string : null
|
||||||
|
|
||||||
|
// 2. 创建或更新运单记录
|
||||||
|
// 先查询是否存在
|
||||||
|
const { data: existingWaybills } = await supa.from('platform_express_waybills').select('id').eq('order_no', orderNo).execute()
|
||||||
|
let waybillIdForEvent: string | null = null
|
||||||
|
|
||||||
|
const waybillPayload = {
|
||||||
|
order_id: orderId,
|
||||||
|
order_no: orderNo,
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: trackingNo,
|
||||||
|
current_status_code: 'SHIPPED',
|
||||||
|
current_status_text: '商家已发货',
|
||||||
|
last_synced_at: new Date().toISOString(),
|
||||||
|
source: 'mock'
|
||||||
|
} as UTSJSONObject
|
||||||
|
|
||||||
|
if (existingWaybills != null && (existingWaybills as Array<any>).length > 0) {
|
||||||
|
// 更新
|
||||||
|
const waybillRow = (existingWaybills as Array<UTSJSONObject>)[0]
|
||||||
|
waybillIdForEvent = waybillRow['id'] as string
|
||||||
|
await supa.from('platform_express_waybills').update(waybillPayload).eq('id', waybillIdForEvent).execute()
|
||||||
|
} else {
|
||||||
|
// 插入
|
||||||
|
const { data: newWaybill, error: insError } = await supa.from('platform_express_waybills').insert(waybillPayload).select().single().execute()
|
||||||
|
if (insError != null) {
|
||||||
|
console.error('Insert waybill error:', insError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (newWaybill != null) {
|
||||||
|
waybillIdForEvent = (newWaybill as UTSJSONObject)['id'] as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waybillIdForEvent == null) {
|
||||||
|
console.error('Bind shipment error: Failed to get waybill ID')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 更新订单表状态
|
||||||
|
const { error: oError } = await supa.from('ml_orders').update({
|
||||||
|
order_status: 3, // SHIPPED
|
||||||
|
shipping_status: 2, // 已向物流公司提交
|
||||||
|
shipped_at: new Date().toISOString()
|
||||||
|
}).eq('order_no', orderNo).execute()
|
||||||
|
|
||||||
|
if (oError != null) {
|
||||||
|
console.error('Update ml_orders error:', oError)
|
||||||
|
// 继续执行,因为运单记录已经拉起
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 产生初始轨迹
|
||||||
|
await supa.from('platform_express_tracking_events').insert({
|
||||||
|
waybill_id: waybillIdForEvent,
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: trackingNo,
|
||||||
|
event_time: new Date().toISOString(),
|
||||||
|
event_code: 'CREATED',
|
||||||
|
event_text: '商家已发货,等待快递公司揽收',
|
||||||
|
status_code: 'SHIPPED',
|
||||||
|
dedupe_key: 'INIT_' + orderNo + '_' + Date.now(),
|
||||||
|
evidence_urls: []
|
||||||
|
}).execute()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户确认取件逻辑
|
||||||
|
async confirmReceipt(orderNo: string): Promise<boolean> {
|
||||||
|
// 1. 获取运单信息
|
||||||
|
const { data: waybills } = await supa.from('platform_express_waybills')
|
||||||
|
.select('id,carrier,tracking_no')
|
||||||
|
.eq('order_no', orderNo)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (waybills == null || (waybills as Array<any>).length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const waybill = (waybills as Array<UTSJSONObject>)[0]
|
||||||
|
const waybillId = waybill['id'] as string
|
||||||
|
const carrier = waybill['carrier'] as string
|
||||||
|
const trackingNo = waybill['tracking_no'] as string
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 2. 更新运单主表状态
|
||||||
|
await supa.from('platform_express_waybills').update({
|
||||||
|
current_status_code: 'DELIVERED',
|
||||||
|
current_status_text: '用户已确认取件',
|
||||||
|
last_synced_at: now
|
||||||
|
}).eq('id', waybillId).execute()
|
||||||
|
|
||||||
|
// 3. 更新订单主表状态 (DEVLIVERED = 4)
|
||||||
|
await supa.from('ml_orders').update({
|
||||||
|
order_status: 4,
|
||||||
|
delivered_at: now
|
||||||
|
}).eq('order_no', orderNo).execute()
|
||||||
|
|
||||||
|
// 4. 插入取件轨迹
|
||||||
|
await supa.from('platform_express_tracking_events').insert({
|
||||||
|
waybill_id: waybillId,
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: trackingNo,
|
||||||
|
event_time: now,
|
||||||
|
event_code: 'USER_PICKUP',
|
||||||
|
event_text: '用户已确认取件,感谢使用',
|
||||||
|
status_code: 'DELIVERED',
|
||||||
|
dedupe_key: 'CONFIRM_' + orderNo + '_' + Date.now(),
|
||||||
|
evidence_urls: []
|
||||||
|
}).execute()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心功能:模拟第三方回调接口 (写库版)
|
||||||
|
*/
|
||||||
|
async pushWebhookData(payload: UTSJSONObject): Promise<boolean> {
|
||||||
|
const tracking_no = (payload['mailNo'] != null) ? payload['mailNo'] as string : (payload['tracking_no'] as string)
|
||||||
|
const yto_status = (payload['infoContent'] != null) ? payload['infoContent'] as string : (payload['status_code'] as string)
|
||||||
|
const event_text = (payload['remark'] != null) ? payload['remark'] as string : (payload['event_text'] as string)
|
||||||
|
const order_no = payload['txLogisticId'] as string || ''
|
||||||
|
const carrier = payload['carrier'] as string || '圆通速递'
|
||||||
|
|
||||||
|
console.log('--- Webhook Push Start ---')
|
||||||
|
console.log('Target:', tracking_no, order_no)
|
||||||
|
|
||||||
|
// 1. 记录原始日志 (Raw)
|
||||||
|
await supa.from('platform_express_event_raw').insert({
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: tracking_no,
|
||||||
|
body: payload,
|
||||||
|
received_at: new Date().toISOString(),
|
||||||
|
signature_valid: true
|
||||||
|
}).execute()
|
||||||
|
|
||||||
|
// 2. 查找运单 ID (手动分步查询)
|
||||||
|
let final_id: string = ""
|
||||||
|
|
||||||
|
// 尝试按单号
|
||||||
|
const res1 = await supa.from('platform_express_waybills').select('id').eq('tracking_no', tracking_no).execute()
|
||||||
|
const list1 = res1.data as Array<UTSJSONObject> | null
|
||||||
|
if (list1 != null && list1.length > 0) {
|
||||||
|
final_id = list1[0]['id'] as string
|
||||||
|
} else {
|
||||||
|
// 尝试按订单号
|
||||||
|
const res2 = await supa.from('platform_express_waybills').select('id').eq('order_no', order_no).execute()
|
||||||
|
const list2 = res2.data as Array<UTSJSONObject> | null
|
||||||
|
if (list2 != null && list2.length > 0) {
|
||||||
|
final_id = list2[0]['id'] as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (final_id == "") {
|
||||||
|
console.error('CRITICAL: Waybill NOT found in DB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found Waybill ID:', final_id)
|
||||||
|
|
||||||
|
// 状态映射 (根据 状态映射表.md 规范)
|
||||||
|
let status_code = 'IN_TRANSIT'
|
||||||
|
if (yto_status === 'GOT' || yto_status === 'SEND' || yto_status === 'TRANSIT') {
|
||||||
|
status_code = 'IN_TRANSIT'
|
||||||
|
} else if (yto_status === 'SENT') {
|
||||||
|
status_code = 'OUT_FOR_DELIVERY'
|
||||||
|
} else if (yto_status === 'PICKUP') {
|
||||||
|
status_code = 'READY_FOR_PICKUP'
|
||||||
|
} else if (yto_status === 'SIGNED' || yto_status === 'DELIVERED') {
|
||||||
|
status_code = 'DELIVERED'
|
||||||
|
} else if (yto_status === 'FAILED' || yto_status === 'EXCEPTION') {
|
||||||
|
status_code = 'EXCEPTION'
|
||||||
|
} else if (yto_status === 'RETURNED') {
|
||||||
|
status_code = 'RETURNED'
|
||||||
|
} else {
|
||||||
|
// 容错:如果是已知的 8 大状态之一,直接使用
|
||||||
|
const validStates = ['ORDER_PLACED', 'SHIPPED', 'IN_TRANSIT', 'OUT_FOR_DELIVERY', 'READY_FOR_PICKUP', 'DELIVERED', 'EXCEPTION', 'RETURNED']
|
||||||
|
if (validStates.indexOf(yto_status) > -1) {
|
||||||
|
status_code = yto_status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 执行更新 (确保 ID 不为空)
|
||||||
|
const updateRes = await supa.from('platform_express_waybills').update({
|
||||||
|
current_status_code: status_code,
|
||||||
|
current_status_text: event_text,
|
||||||
|
last_synced_at: new Date().toISOString()
|
||||||
|
}).eq('id', final_id).execute()
|
||||||
|
|
||||||
|
if (updateRes.error != null) {
|
||||||
|
console.error('Update Waybill Error:', updateRes.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 插入轨迹 (确保 ID 不为空)
|
||||||
|
const acceptTimeRaw = payload['acceptTime'] as string
|
||||||
|
let eventTimeToStore = new Date().toISOString()
|
||||||
|
if (acceptTimeRaw != null && acceptTimeRaw != '') {
|
||||||
|
const normalized = (acceptTimeRaw.indexOf('T') > -1) ? acceptTimeRaw : acceptTimeRaw.replace(' ', 'T')
|
||||||
|
const parsed = new Date(normalized)
|
||||||
|
const parsedMs = parsed.getTime()
|
||||||
|
if (parsedMs == parsedMs) {
|
||||||
|
eventTimeToStore = parsed.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventRes = await supa.from('platform_express_tracking_events').insert({
|
||||||
|
waybill_id: final_id,
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: tracking_no,
|
||||||
|
event_time: eventTimeToStore,
|
||||||
|
event_code: yto_status,
|
||||||
|
event_text: event_text,
|
||||||
|
status_code: status_code,
|
||||||
|
raw_payload: payload,
|
||||||
|
dedupe_key: 'WEBHOOK_' + Date.now()
|
||||||
|
}).execute()
|
||||||
|
|
||||||
|
if (eventRes.error != null) {
|
||||||
|
console.error('Insert Event Error:', eventRes.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMockWebhookLogs(): Promise<MockWebhookLog[]> {
|
||||||
|
const { data } = await supa.from('platform_express_event_raw')
|
||||||
|
.select('*')
|
||||||
|
.order('received_at', { ascending: false })
|
||||||
|
.limit(20)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (data == null) return []
|
||||||
|
const list = data as Array<UTSJSONObject>
|
||||||
|
return list.map((item: UTSJSONObject): MockWebhookLog => {
|
||||||
|
const body = item['body'] as UTSJSONObject
|
||||||
|
return {
|
||||||
|
time: this.formatDBTime(item['received_at'] as string).split(' ')[1],
|
||||||
|
carrier: item['carrier'] as string || 'Unknown',
|
||||||
|
tracking_no: item['tracking_no'] as string || '-',
|
||||||
|
event_code: body['infoContent'] as string || 'Raw',
|
||||||
|
success: true,
|
||||||
|
result_text: '接收成功',
|
||||||
|
payload: body
|
||||||
|
} as MockWebhookLog
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFromCloud(id: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(true), 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (!phone) return ''
|
||||||
|
if (phone.length < 7) return phone
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDBTime(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const Y = d.getFullYear()
|
||||||
|
const M = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||||
|
const D = d.getDate().toString().padStart(2, '0')
|
||||||
|
const h = d.getHours().toString().padStart(2, '0')
|
||||||
|
const m = d.getMinutes().toString().padStart(2, '0')
|
||||||
|
return `${Y}-${M}-${D} ${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的状态文本转换工具
|
||||||
|
*/
|
||||||
|
public getStatusText(status: string): string {
|
||||||
|
const text = LogisticsConstants.STATUS_MAP[status] as string | null
|
||||||
|
return (text != null) ? text : status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依照需求文档:展示第三方回传的原始轨迹文案
|
||||||
|
*/
|
||||||
|
public getDisplayMessage(text: string, status: string): string {
|
||||||
|
if (!text) return '暂无动态'
|
||||||
|
|
||||||
|
// 不再进行脱敏/泛化处理,直接返回原始文案
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的状态图标转换工具
|
||||||
|
*/
|
||||||
|
public getStatusIcon(status: string): string {
|
||||||
|
const icon = LogisticsConstants.STATUS_ICONS[status] as string | null
|
||||||
|
return (icon != null) ? icon : '📦'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockService = new MockService()
|
||||||
204
pages/mall/delivery/test/platform-config-center.uvue
Normal file
204
pages/mall/delivery/test/platform-config-center.uvue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">配送/对接配置中心</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 环境选择 -->
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-header">
|
||||||
|
<text class="card-title">运行环境</text>
|
||||||
|
<switch :checked="isTestMode" @change="toggleTestMode" color="#409eff" />
|
||||||
|
</view>
|
||||||
|
<view class="env-info">
|
||||||
|
<text class="env-tag" :class="isTestMode ? 'tag-orange' : 'tag-blue'">{{ isTestMode ? '测试 (MOCK 模式开启)' : '生产 (已连接真实承运商)' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Mock Server 配置 -->
|
||||||
|
<view v-if="isTestMode" class="card">
|
||||||
|
<view class="card-title">MOCK 服务配置</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">Server 地址</text>
|
||||||
|
<input v-model="config.mockUrl" class="input" placeholder="http://192.168..." />
|
||||||
|
</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">Webhook 接收密钥 (Secret)</text>
|
||||||
|
<input v-model="config.webhookSecret" class="input" password placeholder="HMAC Secret" />
|
||||||
|
</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">自动推送场景</text>
|
||||||
|
<checkbox-group @change="onScenarioChange">
|
||||||
|
<label class="checkbox-item">
|
||||||
|
<checkbox value="auto" :checked="config.autoPush" /> 揽收后自动触发生命周期
|
||||||
|
</label>
|
||||||
|
</checkbox-group>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 承运商列表 -->
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-header">
|
||||||
|
<text class="card-title">已接入承运商</text>
|
||||||
|
<text class="add-link">+ 新增</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="(carrier, index) in carriers" :key="index" class="carrier-item">
|
||||||
|
<view class="carrier-info">
|
||||||
|
<text class="carrier-name">{{ carrier.name }} ({{ carrier.code }})</text>
|
||||||
|
<text class="carrier-meta">回调: {{ carrier.callback_status ? '在线' : '离线' }} | 轮询: {{ carrier.polling ? '开' : '关' }}</text>
|
||||||
|
</view>
|
||||||
|
<switch :checked="carrier.enabled" color="#409eff" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="save-btn" @click="saveConfig">保存配置</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import { mockService, LogisticsConstants } from './mock-service.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isTestMode: mockService.isTestMode,
|
||||||
|
config: {
|
||||||
|
mockUrl: mockService.mockUrl,
|
||||||
|
webhookSecret: '********',
|
||||||
|
autoPush: mockService.autoPush
|
||||||
|
},
|
||||||
|
carriers: LogisticsConstants.CARRIERS.map((c : UTSJSONObject) : UTSJSONObject => {
|
||||||
|
return {
|
||||||
|
name: c['label'],
|
||||||
|
code: c['value'],
|
||||||
|
enabled: true,
|
||||||
|
callback_status: true,
|
||||||
|
polling: false
|
||||||
|
} as UTSJSONObject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
toggleTestMode(e: any) {
|
||||||
|
const val = e.detail.value as boolean
|
||||||
|
this.isTestMode = val
|
||||||
|
mockService.isTestMode = val // 同步到 Mock 服务
|
||||||
|
},
|
||||||
|
onScenarioChange(e: any) {
|
||||||
|
const isAuto = e.detail.value.includes('auto')
|
||||||
|
this.config.autoPush = isAuto
|
||||||
|
mockService.autoPush = isAuto // 同步到 Mock 服务
|
||||||
|
},
|
||||||
|
saveConfig() {
|
||||||
|
uni.showLoading({ title: '保存中...' })
|
||||||
|
setTimeout(() => {
|
||||||
|
mockService.mockUrl = this.config.mockUrl
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '全局配置已同步' })
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.env-tag {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
.tag-orange { background-color: #fdf6ec; color: #e6a23c; }
|
||||||
|
.tag-blue { background-color: #ecf5ff; color: #409eff; }
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
border: 1rpx solid #dcdfe6;
|
||||||
|
padding: 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.checkbox-item {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #606266;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
.add-link {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.carrier-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f6fc;
|
||||||
|
}
|
||||||
|
.carrier-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #303133;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.carrier-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.save-btn {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
pages/mall/delivery/test/platform-tracking-query.uvue
Normal file
300
pages/mall/delivery/test/platform-tracking-query.uvue
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">运单/轨迹查询 (运维排障)</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-section">
|
||||||
|
<input v-model="searchQuery" placeholder="请输入运单号或订单号" class="search-input" />
|
||||||
|
<button class="search-btn" @click="doSearch">查询</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="waybillInfo" class="result-section">
|
||||||
|
<!-- 运单摘要 -->
|
||||||
|
<view class="card summary-card">
|
||||||
|
<view class="card-title">基本信息</view>
|
||||||
|
<view class="grid">
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">运单号:</text>
|
||||||
|
<text class="value highlight">{{ waybillInfo.tracking_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">承运商:</text>
|
||||||
|
<text class="value">{{ waybillInfo.carrier }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">订单号:</text>
|
||||||
|
<text class="value">{{ waybillInfo.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">当前状态:</text>
|
||||||
|
<text class="value">{{ getStatusText(waybillInfo.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 轨迹列表 -->
|
||||||
|
<view class="card events-card">
|
||||||
|
<view class="card-title">轨迹列表 (内网入库)</view>
|
||||||
|
<view v-for="(event, index) in events" :key="index" class="event-row">
|
||||||
|
<view class="event-time-col">
|
||||||
|
<text class="event-time">{{ formatTime(event.event_time) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="event-main-col">
|
||||||
|
<text class="event-text">{{ event.event_text }}</text>
|
||||||
|
<view class="event-meta">
|
||||||
|
<text class="meta-tag blue">代码: {{ event.event_code }}</text>
|
||||||
|
<text class="meta-tag orange" v-if="index === 0 || event.status_code !== events[index - 1].status_code">
|
||||||
|
映射状态: {{ getStatusMapping(event.status_code) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="raw-box" v-if="showRaw[index]">
|
||||||
|
<text class="raw-text">{{ JSON.stringify(event.raw_payload, null, 2) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="toggle-raw" @click="toggleRaw(index)">{{ showRaw[index] ? '折叠原始报文' : '查看原始报文' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Mock 控制板 (仅测试环境) -->
|
||||||
|
<view v-if="isTestEnv" class="card mock-card">
|
||||||
|
<view class="card-title">Mock 控制台 (测试辅助)</view>
|
||||||
|
<view class="action-grid">
|
||||||
|
<button class="mock-btn" @click="runScenario('full')">生成全流程轨迹</button>
|
||||||
|
<button class="mock-btn" @click="runScenario('exception')">注入异常节点</button>
|
||||||
|
<button class="mock-btn" @click="runScenario('standard')">步进下一节点</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="hasSearched" class="empty-section">
|
||||||
|
<text>未找到相关运单记录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script uts>
|
||||||
|
import { mockService, MockOrder, MockTrackingEvent } from './mock-service.uts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchQuery: 'YD987654321',
|
||||||
|
hasSearched: false,
|
||||||
|
waybillInfo: null as MockOrder | null,
|
||||||
|
events: [] as MockTrackingEvent[],
|
||||||
|
showRaw: [] as boolean[],
|
||||||
|
isTestEnv: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
async doSearch() {
|
||||||
|
if (!this.searchQuery) return
|
||||||
|
uni.showLoading({ title: '查询中...' })
|
||||||
|
|
||||||
|
this.hasSearched = true
|
||||||
|
const tracking = await mockService.getMockTracking(this.searchQuery)
|
||||||
|
const orders = await mockService.getMockOrders()
|
||||||
|
const order = orders.find((o: MockOrder): boolean => o.tracking_no === this.searchQuery || o.order_no === this.searchQuery)
|
||||||
|
|
||||||
|
if (order != null) {
|
||||||
|
this.waybillInfo = order
|
||||||
|
this.events = tracking
|
||||||
|
} else {
|
||||||
|
this.waybillInfo = null
|
||||||
|
this.events = []
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showRaw = new Array(this.events.length).fill(false)
|
||||||
|
uni.hideLoading()
|
||||||
|
},
|
||||||
|
formatTime(time: string) : string {
|
||||||
|
return time
|
||||||
|
},
|
||||||
|
toggleRaw(index: number) {
|
||||||
|
const current = this.showRaw[index]
|
||||||
|
this.showRaw[index] = !current
|
||||||
|
},
|
||||||
|
getStatusText(status: string) : string {
|
||||||
|
return mockService.getStatusText(status)
|
||||||
|
},
|
||||||
|
getStatusMapping(code: string) : string {
|
||||||
|
return mockService.getStatusText(code)
|
||||||
|
},
|
||||||
|
async runScenario(type: string) {
|
||||||
|
if (this.waybillInfo == null) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认模拟',
|
||||||
|
content: `是否对 ${this.waybillInfo!.tracking_no} 运行【${type}】场景模拟?`,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
mockService.runScenario(this.waybillInfo!.tracking_no, type).then(() => {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已触发' })
|
||||||
|
this.doSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1rpx solid #dcdfe6;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
.search-btn {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #606266;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #ebeef5;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: #909399;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.value.highlight {
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.event-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1rpx solid #f2f6fc;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
.event-time-col {
|
||||||
|
width: 200rpx;
|
||||||
|
}
|
||||||
|
.event-time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.event-main-col {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.event-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.event-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.meta-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
.meta-tag.blue {
|
||||||
|
background-color: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.meta-tag.orange {
|
||||||
|
background-color: #fdf6ec;
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
.toggle-raw {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.raw-box {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
padding: 16rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
margin: 10rpx 0;
|
||||||
|
}
|
||||||
|
.raw-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #606266;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.mock-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
background-color: #f4f4f5;
|
||||||
|
color: #909399;
|
||||||
|
border: 1rpx solid #dcdfe6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
263
pages/mall/delivery/test/platform-webhook-logs.uvue
Normal file
263
pages/mall/delivery/test/platform-webhook-logs.uvue
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header">
|
||||||
|
<text class="back-link" @click="goBack">⬅ 返回</text>
|
||||||
|
<text class="title">Webhook 接收日志</text>
|
||||||
|
<text class="subtitle">监控推送到达、验签与入库状态</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stats-bar">
|
||||||
|
<view class="stat-box">
|
||||||
|
<text class="stat-val green">99.8%</text>
|
||||||
|
<text class="stat-lab">成功率</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-box">
|
||||||
|
<text class="stat-val red">24</text>
|
||||||
|
<text class="stat-lab">验签失败</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-box">
|
||||||
|
<text class="stat-val blue">1.2s</text>
|
||||||
|
<text class="stat-lab">平均延迟</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="log-list" scroll-y="true">
|
||||||
|
<view v-for="(log, index) in logs" :key="index" class="log-item">
|
||||||
|
<view class="log-header">
|
||||||
|
<view class="log-status" :class="log.success ? 'bg-success' : 'bg-danger'"></view>
|
||||||
|
<text class="log-time">{{ log.time }}</text>
|
||||||
|
<text class="log-carrier">{{ log.carrier }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="log-body">
|
||||||
|
<view class="log-row">
|
||||||
|
<text class="log-label">单号:</text>
|
||||||
|
<text class="log-value">{{ log.tracking_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="log-row">
|
||||||
|
<text class="log-label">事件:</text>
|
||||||
|
<text class="log-value">{{ getEventText(log.event_code) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="log-row">
|
||||||
|
<text class="log-label">状态:</text>
|
||||||
|
<text class="log-value" :class="log.success ? 'text-success' : 'text-danger'">{{ log.result_text }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="log-actions">
|
||||||
|
<text class="action-link" @click="viewPayload(log)">查看 Payload</text>
|
||||||
|
<text class="action-link" @click="reProcess(log)" v-if="!log.success">尝试重新入库</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- Payload 详情抽屉 -->
|
||||||
|
<view v-if="showPayload" class="drawer-mask" @click="showPayload = false">
|
||||||
|
<view class="drawer-content" @click.stop>
|
||||||
|
<view class="drawer-header">
|
||||||
|
<text>原始报文明细</text>
|
||||||
|
<text class="close" @click="showPayload = false">×</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="drawer-body" scroll-y="true">
|
||||||
|
<view class="payload-box">
|
||||||
|
<text class="payload-text">{{ currentPayload }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="uts">
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import { mockService, MockWebhookLog } from './mock-service.uts'
|
||||||
|
|
||||||
|
const logs = ref([] as MockWebhookLog[])
|
||||||
|
const showPayload = ref(false)
|
||||||
|
const currentPayload = ref('')
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
uni.showLoading({ title: '加载中...' })
|
||||||
|
const data = await mockService.getMockWebhookLogs()
|
||||||
|
logs.value = [...data]
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewPayload(log: MockWebhookLog) {
|
||||||
|
currentPayload.value = JSON.stringify(log.payload, null, 2)
|
||||||
|
showPayload.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventText(code: string) : string {
|
||||||
|
return mockService.getStatusText(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reProcess(log: MockWebhookLog) {
|
||||||
|
uni.showLoading({ title: '重送中...' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '重试成功: 已重新入库', icon: 'success' })
|
||||||
|
log.success = true
|
||||||
|
log.result_text = '成功入库 (手动补录)'
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #3498db;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.stat-box {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.stat-val {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.stat-lab {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
.green { color: #27ae60; }
|
||||||
|
.red { color: #e74c3c; }
|
||||||
|
.blue { color: #3498db; }
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
border-left: 8rpx solid transparent;
|
||||||
|
}
|
||||||
|
.bg-success { background-color: #27ae60; }
|
||||||
|
.bg-danger { background-color: #e74c3c; }
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.log-status {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
.log-time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
.log-carrier {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
.log-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.log-label {
|
||||||
|
width: 100rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
.log-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
.text-success { color: #27ae60; }
|
||||||
|
.text-danger { color: #e74c3c; }
|
||||||
|
|
||||||
|
.log-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 30rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #f1f1f1;
|
||||||
|
}
|
||||||
|
.action-link {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.drawer-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
height: 70%;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.drawer-header {
|
||||||
|
padding: 30rpx;
|
||||||
|
border-bottom: 1rpx solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.drawer-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
.payload-box {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.payload-text {
|
||||||
|
color: #ecf0f1;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md
Normal file
84
pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
**概述**
|
||||||
|
- **目标**: 让快递合作方(例如韵达)能安全、可靠地向系统的 `/webhook/express/status` 发送回调,用于运单状态同步与推送触发。
|
||||||
|
- **范围**: 包含鉴权/验签约定、幂等策略、测试样例、部署与运维建议。
|
||||||
|
|
||||||
|
**接入概览**
|
||||||
|
- **Endpoint**: `https://<your-domain>/webhook/express/status`
|
||||||
|
- **协议**: HTTPS(TLS 必需)
|
||||||
|
- **数据格式**: `application/json`,body 为第三方原始回调 JSON。
|
||||||
|
|
||||||
|
**鉴权与验签**
|
||||||
|
- **方式**: 每个合作方分配一份 HMAC secret(UTF-8 文本),服务端按 `client_id` 查秘钥(建议把 `client_id` 放在请求头 `X-Client-Id`)。
|
||||||
|
- **签名头**: `X-Timestamp`(UTC 秒)与 `X-Signature`(hex 小写)。
|
||||||
|
- **签名算法**: HMAC-SHA256(secret, rawBodyText + timestamp) → hex。
|
||||||
|
- **时窗**: 接收端按 ±300 秒(可配置)判定时间窗,超时拒绝(防重放)。
|
||||||
|
- **严格模式**: `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 时签名不合将直接 401(上线初期可先设 false,观测后切为 true)。
|
||||||
|
|
||||||
|
**幂等与去重**
|
||||||
|
- **推荐字段**: 合作方可在 body 中提供 `dedupe_key`(优先使用);若无,服务端基于稳定字段计算哈希(建议用 `tracking_no|carrier|event_time|event_code` 组合)。
|
||||||
|
- **数据库约束**: `platform_express_tracking_events` 应有唯一约束 `(waybill_id, dedupe_key)`,重复回调不再写入事件表。
|
||||||
|
- **返回语义**: 对重复回调可返回 200 并在 body 标注 `duplicate: true`,以便合作方不再重试。
|
||||||
|
|
||||||
|
**请求/响应与重试策略**
|
||||||
|
- **接收端快速 ACK**: 推荐返回 HTTP 202 Accepted(或 200)表示“已入队/接收”,不代表处理完成。
|
||||||
|
- **错误返回**: 4xx 表示请求问题(签名/格式),5xx 表示服务端异常(合作方可按重试策略重试)。
|
||||||
|
- **建议重试规则**(对方遵守): 3–5 次,指数退避(例如 1m, 2m, 4m, ...),总重试时长不超过 1 小时。
|
||||||
|
|
||||||
|
**示例:签名与发送(bash)**
|
||||||
|
```bash
|
||||||
|
TS=$(date -u +%s)
|
||||||
|
BODY='{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
|
||||||
|
SECRET='YOUR_PARTNER_SECRET'
|
||||||
|
SIG=$(printf '%s%s' "$BODY" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
|
||||||
|
curl -v -X POST https://your-domain/webhook/express/status \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Timestamp: $TS" \
|
||||||
|
-H "X-Signature: $SIG" \
|
||||||
|
-H "X-Client-Id: partner-yd" \
|
||||||
|
-d "$BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:PowerShell(Windows)**
|
||||||
|
```powershell
|
||||||
|
$ts = [int][double]::Parse((Get-Date).ToUniversalTime().Subtract([datetime]'1970-01-01').TotalSeconds)
|
||||||
|
$body = '{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
|
||||||
|
$secret = 'YOUR_PARTNER_SECRET'
|
||||||
|
$hmac = New-Object System.Security.Cryptography.HMACSHA256([Text.Encoding]::UTF8.GetBytes($secret))
|
||||||
|
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($body + $ts))
|
||||||
|
$sig = ($hash | ForEach-Object { $_.ToString("x2") }) -join ''
|
||||||
|
Invoke-RestMethod -Uri 'https://your-domain/webhook/express/status' -Method Post -Body $body -ContentType 'application/json' -Headers @{ 'X-Timestamp'=$ts; 'X-Signature'=$sig; 'X-Client-Id'='partner-yd' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试与沙箱**
|
||||||
|
- **临时隧道**: 联调期可使用 `ngrok` 或 `cloudflared` 将本地服务映射到公网供合作方发送测试回调。
|
||||||
|
- **测试密钥**: 为每个合作方发放 `test_secret` 与 `prod_secret`,并在系统中分离管理。
|
||||||
|
- **回放工具**: 提供一个简单 `test-send.js`(仓库已有)并在 README 中示例化如何运行。
|
||||||
|
|
||||||
|
**部署与网关建议**
|
||||||
|
- **域名与 TLS**: 使用 `webhook.<your-domain>`,证书推荐用 Let's Encrypt 自动续期。
|
||||||
|
- **反向代理 / 网关**: 推荐放在 Nginx / Kong / Cloud Load Balancer 前做:
|
||||||
|
- TLS 终端、IP 白名单、速率限制(rate-limiting)、日志落盘与审计。
|
||||||
|
- 如使用 Kong,可在网关做 `key-auth` 或 IP 白名单做第一道防护。
|
||||||
|
- **WAF/防护**: 对外公开时开启基础 WAF(过滤常见攻击)与 DDOS 保护。
|
||||||
|
|
||||||
|
**监控与告警**
|
||||||
|
- **关键 Metric**: 接收量、验签失败率、重复率(duplicate percent)、处理延时、no-targets 率。
|
||||||
|
- **告警阈值**: 验签失败率 >1% 或 no-targets 突增 触发 PagerDuty/邮件告警。
|
||||||
|
|
||||||
|
**上线流程(最小安全步骤)**
|
||||||
|
1. 在测试环境配置 partner test_secret 与 `X-Client-Id`,提供示例脚本。
|
||||||
|
2. 使用 `ngrok` 联调,通过 2–3 次真实回调验证 `dedupe_key`、waybill 匹配和入队。
|
||||||
|
3. 把 partner 的 prod_secret 写入生产密钥存储(避免明文在 repo)。
|
||||||
|
4. 在生产网关启用 IP 白名单或 key-auth,并在低流量窗口把 `WEBHOOK_REJECT_INVALID_SIGNATURE` 改为 `true`。
|
||||||
|
|
||||||
|
**常见问题与排查**
|
||||||
|
- **验签失败**: 检查是否对方用了 stringify 后的 body 或时间戳单位不一致(秒 vs 毫秒);建议双方按示例脚本校验。
|
||||||
|
- **重复回调仍写入**: 检查 `dedupe_key` 的字段组合与 DB 唯一约束 `(waybill_id,dedupe_key)` 是否生效。
|
||||||
|
- **no-targets**: 确保 `push_devices` 已按 `merchant_id` 或 `user_id` 正确写入(见 `GET /api/v1/push/devices` 验证)。
|
||||||
|
|
||||||
|
**联系人与上游联调清单**
|
||||||
|
- **提供方需给出**: 回调样例(真实 JSON)、回调频率预估、回调 IP 段、是否支持 `dedupe_key` 字段、联系人邮箱/电话。
|
||||||
|
- **我方需要提供**: `test_secret`、测试域名、示例脚本、接入说明(本文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
作者:自动生成;可根据合作方反馈补充示例与节流策略。
|
||||||
157
pages/mall/delivery/webhook-server/README.md
Normal file
157
pages/mall/delivery/webhook-server/README.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Webhook 接收器 — 说明
|
||||||
|
|
||||||
|
路径:`pages/mall/delivery/webhook-server/webhook-receiver.js`
|
||||||
|
|
||||||
|
目的:接收承运方或 Mock Server 的 HTTP 回调(POST /webhook/express/status),将原始回文写入 `platform_express_event_raw`,并按项目现有映射更新 `platform_express_waybills` 与写入 `platform_express_tracking_events`。
|
||||||
|
|
||||||
|
环境变量(必须/可选):
|
||||||
|
- `SUPA_URL`:Supabase REST 地址(示例 `http://192.168.1.62:18000`)
|
||||||
|
- `SUPA_KEY`:Supabase service_role 或 anon key(用于 REST 写入)
|
||||||
|
- `SUPA_USE_BEARER`(可选):是否附加 `Authorization: Bearer <SUPA_KEY>`,默认 `false`。
|
||||||
|
- 在一些自托管 Supabase/Kong(key-auth)环境中,**只需要** `apikey`;如果误加 Bearer 且 key 不是 JWT,可能出现 `PGRST301`("None of the keys was able to decode the JWT")。
|
||||||
|
- `WEBHOOK_SECRET`(可选):与第三方共享的 HMAC-SHA256 secret,用于校验 `X-Signature`(签名为 hex)
|
||||||
|
- `WEBHOOK_REJECT_INVALID_SIGNATURE`(可选):若为 `true`,且配置了 `WEBHOOK_SECRET`,则验签失败会直接返回 HTTP 401(默认不拒绝,只记录)。
|
||||||
|
- `WEBHOOK_PORT`(可选):接收器监听端口,默认 `7201`(推荐用这个,便于与 push-server 共享同一份 `server/config.json`)
|
||||||
|
- `PORT`(可选):接收器监听端口(兼容旧用法;若共享 `server/config.json` 且其中 `PORT=7301`,会导致端口冲突)
|
||||||
|
|
||||||
|
配置方式(推荐用配置文件,避免与其他服务端口冲突):
|
||||||
|
- **同目录配置文件(推荐)**:在 `webhook-receiver.js` 同目录放置 `webhook.config.json`,启动时会自动读取。
|
||||||
|
- **显式指定配置文件**:设置 `CONFIG_FILE` / `CONFIG_PATH` 指向你的 `.env` 或 `.json`。
|
||||||
|
- **回退加载**:若未指定 `CONFIG_FILE` 且同目录无 `webhook.config.json`,会尝试读取 `server/.env` 与 `server/config.json`(由 `server/load-config.js` 负责)。
|
||||||
|
|
||||||
|
示例配置文件:仓库内提供 [server/webhook.config.json.example](server/webhook.config.json.example),你可以复制一份到本目录作为 `webhook.config.json` 使用。
|
||||||
|
|
||||||
|
启动(PowerShell):
|
||||||
|
```powershell
|
||||||
|
node pages/mall/delivery/webhook-server/webhook-receiver.js
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐:使用同目录配置文件启动(PowerShell):
|
||||||
|
```powershell
|
||||||
|
# 复制示例并填写真实 SUPA_KEY
|
||||||
|
Copy-Item .\server\webhook.config.json.example .\pages\mall\delivery\webhook-server\webhook.config.json
|
||||||
|
|
||||||
|
node pages/mall/delivery/webhook-server/webhook-receiver.js
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你使用显式 CONFIG_FILE:
|
||||||
|
```powershell
|
||||||
|
$env:CONFIG_FILE=(Resolve-Path .\pages\mall\delivery\webhook-server\webhook.config.json)
|
||||||
|
node pages/mall/delivery/webhook-server/webhook-receiver.js
|
||||||
|
```
|
||||||
|
|
||||||
|
启动(Linux / macOS / WSL):
|
||||||
|
```bash
|
||||||
|
export SUPA_URL='http://192.168.1.62:18000'
|
||||||
|
export SUPA_KEY='your_service_role_key'
|
||||||
|
export WEBHOOK_SECRET='your-secret' # optional
|
||||||
|
node pages/mall/delivery/webhook-server/webhook-receiver.js
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以用配置文件(更适合长期运行):
|
||||||
|
- `server/load-config.js` 会自动尝试加载:`server/.env`、`server/config.json`(以及 `CONFIG_FILE/CONFIG_PATH` 指定的文件),并把其中的键注入到 `process.env`。
|
||||||
|
- 因为接收器已在启动时 `require` 了该加载器,所以你只要把 `SUPA_URL`、`SUPA_KEY` 写进上述文件之一即可。
|
||||||
|
|
||||||
|
如果你不想与 `server/config.json` 共用 `PORT`(避免端口冲突),建议为 webhook 单独准备一个配置文件,然后用 `CONFIG_FILE` 指定:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 复制示例文件并填写真实 SUPA_KEY:
|
||||||
|
Copy-Item .\server\webhook.config.json.example .\server\webhook.config.json
|
||||||
|
|
||||||
|
# 指定配置文件启动(不会影响 push-server 的配置):
|
||||||
|
$env:CONFIG_FILE=(Resolve-Path .\server\webhook.config.json)
|
||||||
|
node pages/mall/delivery/webhook-server/webhook-receiver.js
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:在 `server/.env` 中写入:
|
||||||
|
```env
|
||||||
|
SUPA_URL=http://192.168.1.62:18000
|
||||||
|
SUPA_KEY=your_service_role_key
|
||||||
|
WEBHOOK_SECRET=your-secret
|
||||||
|
PORT=7201
|
||||||
|
```
|
||||||
|
|
||||||
|
测试(curl 模拟第三方推送):
|
||||||
|
```bash
|
||||||
|
BODY='{"mailNo":"TEST_123","infoContent":"SENT","remark":"派送中","acceptTime":"2026-02-25 12:00:00","carrier":"YTO"}'
|
||||||
|
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
# 如果启用了 WEBHOOK_SECRET,计算签名:
|
||||||
|
SIG=$(printf "%s%s" "$BODY" "$TS" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
|
||||||
|
|
||||||
|
curl -i -X POST http://localhost:7201/webhook/express/status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Timestamp: $TS" \
|
||||||
|
-H "X-Client-Id: mock_carrier" \
|
||||||
|
-H "X-Signature: $SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 重要:签名计算必须使用**原始请求体文本**(raw body)。接收器也会使用 raw body 进行验签;不要用 JSON 对象 stringify 后的字符串替代。
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
- `GET http://localhost:7201/health`(端口以 `PORT` 为准)
|
||||||
|
|
||||||
|
常见问题排查:
|
||||||
|
- 返回 `{ ok:false, message:'waybill not found' }`:说明 webhook 已收到请求,但在 `platform_express_waybills` 中找不到 `tracking_no`(或 `order_no`)匹配的记录。
|
||||||
|
- 返回 `502 supabase unauthorized (check SUPA_KEY/SUPA_URL)`:说明当前 `SUPA_KEY` / `SUPA_URL` 无法通过 Supabase REST 鉴权(常见于 key 填错、已失效、URL 不对)。请换成 Supabase 控制台中的真实 `service_role` key,并重启接收器。
|
||||||
|
|
||||||
|
Windows 下保持“持续监听”(后台运行):
|
||||||
|
> 只要 Node 进程还在,webhook 就会持续监听;如果你关闭终端/窗口或按 `Ctrl+C`,进程结束就不会再监听。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 后台启动并把日志写到文件(推荐)
|
||||||
|
Start-Process node -ArgumentList 'pages/mall/delivery/webhook-server/webhook-receiver.js' \
|
||||||
|
-WorkingDirectory (Get-Location) \
|
||||||
|
-RedirectStandardOutput '.\webhook-receiver.log' \
|
||||||
|
-RedirectStandardError '.\webhook-receiver.err.log' \
|
||||||
|
-PassThru
|
||||||
|
|
||||||
|
# 查看是否在监听(把 7201 换成你的 PORT)
|
||||||
|
netstat -ano | findstr :7201
|
||||||
|
|
||||||
|
# 查看健康检查
|
||||||
|
Invoke-RestMethod -Uri 'http://localhost:7201/health' -Method GET | ConvertTo-Json
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务(按 PID 结束进程):
|
||||||
|
```powershell
|
||||||
|
# 先用 netstat 找到 LISTENING 的 PID,然后结束它
|
||||||
|
Stop-Process -Id <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖说明:
|
||||||
|
- 建议使用 Node.js 18+(例如你当前的 Node.js 22),已内置 `fetch`,无需安装 `node-fetch`。
|
||||||
|
- 若使用更老的 Node 且没有 `fetch`,需要安装 `node-fetch`(并保持 CommonJS 兼容)。
|
||||||
|
|
||||||
|
预期:接口返回 200 JSON {ok:true}(若未找到对应运单会返回 {ok:false, message:'waybill not found'})。
|
||||||
|
|
||||||
|
验证写入(查看 Supabase):
|
||||||
|
```bash
|
||||||
|
# 示例:列最近 5 条原始回文
|
||||||
|
curl -s -H "apikey: $SUPA_KEY" -H "Accept: application/json" \
|
||||||
|
"$SUPA_URL/rest/v1/platform_express_event_raw?select=*&order=received_at.desc&limit=5" | jq .
|
||||||
|
|
||||||
|
# 查看最近轨迹事件
|
||||||
|
curl -s -H "apikey: $SUPA_KEY" -H "Accept: application/json" \
|
||||||
|
"$SUPA_URL/rest/v1/platform_express_tracking_events?select=*&order=created_at.desc&limit=5" | jq .
|
||||||
|
|
||||||
|
# 如果你的环境已确认 Bearer 可用(不会触发 PGRST301),也可以额外加上:
|
||||||
|
# -H "Authorization: Bearer $SUPA_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
与仓库中 Mock 实现的关系:
|
||||||
|
- `pages/mall/delivery/test/mock-service.uts` 已包含写库逻辑(`pushWebhookData`、`bindShipment`、`runScenario`)。新接收器复用了同样的入库思路,但以 HTTP/REST 的形式对外暴露。
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
- 本接收器通过 Supabase REST API 直接写表,使用 `SUPA_KEY` 时请确保权限与密钥安全(不要将 service_role key 公开到前端)。
|
||||||
|
- 如果你的 Supabase 接口部署在内网,请确保接收器能访问 `SUPA_URL`(网络/防火墙)。
|
||||||
|
- 若需要更严格的验签/重放检测/幂等,请告知,我可以把这些能力加入接收器(例如记录并比对 dedupe_key 或检查 X-Timestamp 时间窗口)。
|
||||||
|
|
||||||
|
下一步建议:
|
||||||
|
- 若你要把第三方直接接到该接收器,请把 `WEBHOOK_SECRET` 与对方约定并启用验签。
|
||||||
|
- 若需要我加重放防护或返回 4xx/5xx 更精确的逻辑,也可继续实现。
|
||||||
|
|
||||||
|
文件位置:
|
||||||
|
- [Webhook 接收器](pages/mall/delivery/webhook-server/webhook-receiver.js)
|
||||||
|
|
||||||
|
作者:自动生成(可手动调整)
|
||||||
42
pages/mall/delivery/webhook-server/test-send.js
Normal file
42
pages/mall/delivery/webhook-server/test-send.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const http = require('http')
|
||||||
|
const https = require('https')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 7201
|
||||||
|
const URL = `http://localhost:${PORT}/webhook/express/status`
|
||||||
|
const SECRET = process.env.WEBHOOK_SECRET || 'test_secret'
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tracking_no: 'TEST_YT_20260206_0007',
|
||||||
|
status_code: 'DELIVERED',
|
||||||
|
acceptTime: new Date().toISOString(),
|
||||||
|
remark: 'local test event'
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = JSON.stringify(payload)
|
||||||
|
const ts = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const sig = crypto.createHmac('sha256', SECRET).update(bodyText + ts).digest('hex')
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyText),
|
||||||
|
'X-Timestamp': ts,
|
||||||
|
'X-Signature': sig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = http.request(URL, options, (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (chunk) => data += chunk)
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('STATUS', res.statusCode)
|
||||||
|
try { console.log('BODY', JSON.parse(data)) } catch (e) { console.log('BODY', data) }
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', (e) => { console.error('request error', e); process.exit(2) })
|
||||||
|
req.write(bodyText)
|
||||||
|
req.end()
|
||||||
331
pages/mall/delivery/webhook-server/webhook-receiver.js
Normal file
331
pages/mall/delivery/webhook-server/webhook-receiver.js
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// Load configuration into process.env.
|
||||||
|
// Priority:
|
||||||
|
// 1) Real environment variables
|
||||||
|
// 2) CONFIG_FILE/CONFIG_PATH (explicit)
|
||||||
|
// 3) Local file next to this script: webhook.config.json
|
||||||
|
// 4) server/.env / server/config.json via server/load-config.js
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const localConfigPath = path.join(__dirname, 'webhook.config.json')
|
||||||
|
if (!process.env.CONFIG_FILE && !process.env.CONFIG_PATH && fs.existsSync(localConfigPath)) {
|
||||||
|
process.env.CONFIG_FILE = localConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
require('../../../../server/load-config')
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const bodyParser = require('body-parser')
|
||||||
|
const fetch = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => {
|
||||||
|
try {
|
||||||
|
// Fallback for older Node versions where fetch is not available.
|
||||||
|
return require('node-fetch')
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.")
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
// 支持服务专用端口,避免与 push-server 共用 server/config.json 时发生端口冲突
|
||||||
|
const PORT = process.env.WEBHOOK_PORT || process.env.PORT || 7201
|
||||||
|
const SUPA_URL = (process.env.SUPA_URL || process.env.SUPA_URL_OVERRIDE || '').replace(/\/$/, '')
|
||||||
|
// Prefer service role key when present (server-side), to avoid RLS issues.
|
||||||
|
const SUPA_KEY = process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY || ''
|
||||||
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' // optional HMAC secret
|
||||||
|
|
||||||
|
function supaFetch(path, opts = {}) {
|
||||||
|
const url = `${SUPA_URL}/rest/v1/${path}`
|
||||||
|
// Default to apikey only (compatible with self-hosted Supabase/Kong key-auth).
|
||||||
|
// Only attach Authorization: Bearer when explicitly enabled.
|
||||||
|
const headers = Object.assign({}, opts.headers || {}, {
|
||||||
|
apikey: SUPA_KEY,
|
||||||
|
Accept: 'application/json'
|
||||||
|
})
|
||||||
|
const sendBearer = (process.env.SUPA_USE_BEARER === 'true')
|
||||||
|
if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}`
|
||||||
|
return fetch(url, Object.assign({}, opts, { headers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSignature(bodyText, ts) {
|
||||||
|
if (!WEBHOOK_SECRET) return ''
|
||||||
|
const h = crypto.createHmac('sha256', WEBHOOK_SECRET)
|
||||||
|
h.update(bodyText + (ts || ''))
|
||||||
|
return h.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, event_code, event_text }) {
|
||||||
|
// 目的:让 webhook 重复回调(同一事件)不会重复入库,从而避免重复入队/重复通知。
|
||||||
|
// 注意:优先使用稳定字段组合;不要使用 Date.now() 这类易变字段。
|
||||||
|
const base = JSON.stringify({
|
||||||
|
tracking_no: tracking_no || null,
|
||||||
|
carrier: carrier || null,
|
||||||
|
status_code: status_code || null,
|
||||||
|
event_time: event_time || null,
|
||||||
|
event_code: event_code || null,
|
||||||
|
event_text: (event_text || '').trim().slice(0, 200)
|
||||||
|
})
|
||||||
|
const hex = crypto.createHash('sha256').update(base).digest('hex')
|
||||||
|
return 'WH_' + hex.slice(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
|
||||||
|
try {
|
||||||
|
const received_at = new Date().toISOString()
|
||||||
|
|
||||||
|
const minimal = {
|
||||||
|
carrier: carrier || null,
|
||||||
|
tracking_no: tracking_no || null,
|
||||||
|
body: payload,
|
||||||
|
received_at,
|
||||||
|
signature_valid: signature_valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer inserting richer metadata when the upgraded schema is present.
|
||||||
|
// If the database schema is older (missing columns), fall back to minimal.
|
||||||
|
const meta = arguments && arguments.length >= 5 ? arguments[4] : null
|
||||||
|
const extended = Object.assign({}, minimal, {
|
||||||
|
source: 'webhook',
|
||||||
|
client_id: meta && meta.client_id ? meta.client_id : null,
|
||||||
|
signature: meta && meta.signature ? meta.signature : null,
|
||||||
|
ts_header: meta && meta.ts_header ? meta.ts_header : null,
|
||||||
|
request_id: meta && meta.request_id ? meta.request_id : null,
|
||||||
|
remote_ip: meta && meta.remote_ip ? meta.remote_ip : null,
|
||||||
|
headers: meta && meta.headers ? meta.headers : null,
|
||||||
|
dedupe_key: meta && meta.dedupe_key ? meta.dedupe_key : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const tryInsert = async (row) => {
|
||||||
|
return await supaFetch('platform_express_event_raw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(row)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = await tryInsert(extended)
|
||||||
|
if (resp && !resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
if (resp.status === 400 && /column .* does not exist/i.test(txt)) {
|
||||||
|
resp = await tryInsert(minimal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('upsertRaw error', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findWaybill(tracking_no, order_no) {
|
||||||
|
try {
|
||||||
|
if (tracking_no) {
|
||||||
|
const r = await supaFetch(`platform_express_waybills?tracking_no=eq.${encodeURIComponent(tracking_no)}&select=id,carrier,tracking_no,order_no,order_id`)
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text().catch(() => '')
|
||||||
|
const err = new Error(`Supabase query failed (tracking_no): HTTP ${r.status} ${txt}`)
|
||||||
|
err.status = r.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const data = await r.json()
|
||||||
|
if (data && data.length > 0) return data[0]
|
||||||
|
}
|
||||||
|
if (order_no) {
|
||||||
|
const r2 = await supaFetch(`platform_express_waybills?order_no=eq.${encodeURIComponent(order_no)}&select=id,carrier,tracking_no,order_no,order_id`)
|
||||||
|
if (!r2.ok) {
|
||||||
|
const txt2 = await r2.text().catch(() => '')
|
||||||
|
const err2 = new Error(`Supabase query failed (order_no): HTTP ${r2.status} ${txt2}`)
|
||||||
|
err2.status = r2.status
|
||||||
|
throw err2
|
||||||
|
}
|
||||||
|
const data2 = await r2.json()
|
||||||
|
if (data2 && data2.length > 0) return data2[0]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('findWaybill error', e && e.message ? e.message : e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatus(inStatus) {
|
||||||
|
let s = 'IN_TRANSIT'
|
||||||
|
if (!inStatus) return s
|
||||||
|
const v = String(inStatus).toUpperCase()
|
||||||
|
if (['GOT','SEND','TRANSIT'].indexOf(v) > -1) s = 'IN_TRANSIT'
|
||||||
|
else if (v === 'SENT') s = 'OUT_FOR_DELIVERY'
|
||||||
|
else if (v === 'PICKUP') s = 'READY_FOR_PICKUP'
|
||||||
|
else if (v === 'SIGNED' || v === 'DELIVERED') s = 'DELIVERED'
|
||||||
|
else if (v === 'FAILED' || v === 'EXCEPTION') s = 'EXCEPTION'
|
||||||
|
else if (v === 'RETURNED') s = 'RETURNED'
|
||||||
|
else {
|
||||||
|
const valid = ['ORDER_PLACED','SHIPPED','IN_TRANSIT','OUT_FOR_DELIVERY','READY_FOR_PICKUP','DELIVERED','EXCEPTION','RETURNED']
|
||||||
|
if (valid.indexOf(v) > -1) s = v
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateWaybill(id, status_code, text) {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await supaFetch(`platform_express_waybills?id=eq.${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ current_status_code: status_code, current_status_text: text, last_synced_at: now })
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('updateWaybill error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertEvent(event) {
|
||||||
|
try {
|
||||||
|
// 幂等插入:基于 (waybill_id, dedupe_key) 的唯一约束忽略重复。
|
||||||
|
const resp = await supaFetch('platform_express_tracking_events?on_conflict=waybill_id,dedupe_key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'resolution=ignore-duplicates' },
|
||||||
|
body: JSON.stringify(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
console.warn('insertEvent failed:', `HTTP ${resp.status}`, txt)
|
||||||
|
return { ok: false, status: resp.status, body: txt }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('insertEvent error', e)
|
||||||
|
return { ok: false, status: 0, body: (e && e.message) ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!SUPA_URL || !SUPA_KEY) {
|
||||||
|
console.error('SUPA_URL and SUPA_KEY must be set in env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
// Capture raw body for signature verification.
|
||||||
|
app.use(bodyParser.json({
|
||||||
|
limit: '1mb',
|
||||||
|
verify: (req, res, buf) => {
|
||||||
|
try {
|
||||||
|
req.rawBody = buf ? buf.toString('utf8') : ''
|
||||||
|
} catch (e) {
|
||||||
|
req.rawBody = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/webhook/express/status', async (req, res) => {
|
||||||
|
const ts = req.headers['x-timestamp'] || ''
|
||||||
|
const sig = req.headers['x-signature'] || ''
|
||||||
|
const cid = req.headers['x-client-id'] || ''
|
||||||
|
const bodyText = (req.rawBody && typeof req.rawBody === 'string' && req.rawBody.length > 0)
|
||||||
|
? req.rawBody
|
||||||
|
: JSON.stringify(req.body || {})
|
||||||
|
|
||||||
|
let sigValid = true
|
||||||
|
if (WEBHOOK_SECRET) {
|
||||||
|
const calc = computeSignature(bodyText, ts)
|
||||||
|
sigValid = calc === String(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional strict mode: reject invalid signature when secret is configured.
|
||||||
|
if (WEBHOOK_SECRET && !sigValid && process.env.WEBHOOK_REJECT_INVALID_SIGNATURE === 'true') {
|
||||||
|
return res.status(401).json({ ok: false, message: 'invalid signature' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist raw (best-effort)
|
||||||
|
const tracking_no_raw = req.body && (req.body.mailNo || req.body.tracking_no)
|
||||||
|
const carrier_raw = req.body && (req.body.carrier || req.body.company)
|
||||||
|
const dedupe_key_raw = (() => {
|
||||||
|
try {
|
||||||
|
const base = String(bodyText || '') + '|' + String(ts || '')
|
||||||
|
return 'RAW_' + crypto.createHash('sha256').update(base).digest('hex').slice(0, 32)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await upsertRaw(req.body || {}, tracking_no_raw, carrier_raw, sigValid, {
|
||||||
|
client_id: String(cid || '') || null,
|
||||||
|
signature: String(sig || '') || null,
|
||||||
|
ts_header: String(ts || '') || null,
|
||||||
|
remote_ip: (req.ip || (req.connection && req.connection.remoteAddress) || null),
|
||||||
|
request_id: null,
|
||||||
|
headers: req.headers || null,
|
||||||
|
dedupe_key: dedupe_key_raw
|
||||||
|
})
|
||||||
|
|
||||||
|
// find waybill
|
||||||
|
const tracking_no = req.body && (req.body.mailNo || req.body.tracking_no)
|
||||||
|
const order_no = req.body && (req.body.txLogisticId || req.body.order_no)
|
||||||
|
const carrierIn = req.body && (req.body.carrier || req.body.company || null)
|
||||||
|
const event_code = req.body && (req.body.infoContent || req.body.status_code || req.body.event_code)
|
||||||
|
const event_text = req.body && (req.body.remark || req.body.event_text || '')
|
||||||
|
|
||||||
|
let waybill = null
|
||||||
|
try {
|
||||||
|
waybill = await findWaybill(tracking_no, order_no)
|
||||||
|
} catch (e) {
|
||||||
|
const status = e && e.status ? Number(e.status) : 0
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return res.status(502).json({ ok: false, message: 'supabase unauthorized (check SUPA_KEY/SUPA_URL)' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ ok: false, message: 'supabase query failed' })
|
||||||
|
}
|
||||||
|
if (!waybill || !waybill.id) {
|
||||||
|
// Waybill not found — respond 200 but inform caller in body.
|
||||||
|
return res.status(200).json({ ok: false, message: 'waybill not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const waybillId = waybill.id
|
||||||
|
const carrier = carrierIn || waybill.carrier || null
|
||||||
|
|
||||||
|
const status_code = mapStatus(event_code)
|
||||||
|
|
||||||
|
// update waybill
|
||||||
|
await updateWaybill(waybillId, status_code, event_text)
|
||||||
|
|
||||||
|
// parse event_time
|
||||||
|
let event_time = new Date().toISOString()
|
||||||
|
if (req.body && req.body.acceptTime) {
|
||||||
|
try {
|
||||||
|
const t = req.body.acceptTime.indexOf('T') > -1 ? req.body.acceptTime : req.body.acceptTime.replace(' ', 'T')
|
||||||
|
const dt = new Date(t)
|
||||||
|
if (!isNaN(dt.getTime())) event_time = dt.toISOString()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert event
|
||||||
|
const received_at = new Date().toISOString()
|
||||||
|
const dedupe_key = stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, event_code, event_text })
|
||||||
|
const eventPayload = {
|
||||||
|
waybill_id: waybillId,
|
||||||
|
carrier: carrier,
|
||||||
|
tracking_no: tracking_no || waybill.tracking_no || null,
|
||||||
|
received_at,
|
||||||
|
source: 'webhook',
|
||||||
|
event_id: dedupe_key,
|
||||||
|
event_time: event_time,
|
||||||
|
event_code: event_code || 'UNKNOWN',
|
||||||
|
event_text: event_text || '',
|
||||||
|
status_code: status_code,
|
||||||
|
raw_payload: req.body || {},
|
||||||
|
dedupe_key
|
||||||
|
}
|
||||||
|
const ins = await insertEvent(eventPayload)
|
||||||
|
if (!ins || ins.ok !== true) {
|
||||||
|
return res.status(200).json({ ok: false, message: 'insert tracking_event failed (see webhook-receiver logs)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }))
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => console.log(`Webhook receiver listening on http://0.0.0.0:${PORT}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch(e => { console.error('start failed', e); process.exit(1) })
|
||||||
6
pages/mall/delivery/webhook-server/webhook.config.json
Normal file
6
pages/mall/delivery/webhook-server/webhook.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"SUPA_URL": "http://192.168.1.62:18000",
|
||||||
|
"SUPA_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzM2MjExNDMsImV4cCI6MTkzMTMwMTE0M30.zahVzrUCUkB436SHYyM2muGL3Lg_aocJJgWv1t6PpKg",
|
||||||
|
"WEBHOOK_SECRET": "",
|
||||||
|
"PORT": "7201"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"SUPA_URL": "http://192.168.1.62:18000",
|
||||||
|
"SUPA_KEY": "PASTE_YOUR_SERVICE_ROLE_KEY_HERE",
|
||||||
|
"WEBHOOK_SECRET": "",
|
||||||
|
"PORT": "7201"
|
||||||
|
}
|
||||||
@@ -141,8 +141,10 @@
|
|||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
import { AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||||
import { getCurrentUser, logout } from '@/utils/store.uts'
|
import { IS_TEST_MODE, PUSH_SERVER_URL } from '@/ak/config.uts'
|
||||||
|
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
|
||||||
|
import { UserProfile } from '@/pages/user/types.uts'
|
||||||
import { ensureUserProfile } from '@/utils/sapi.uts'
|
import { ensureUserProfile } from '@/utils/sapi.uts'
|
||||||
|
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
@@ -173,17 +175,20 @@ const codeCountdown = ref<number>(0)
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
try {
|
try {
|
||||||
if (IS_TEST_MODE) return
|
|
||||||
const sessionInfo = supa.getSession()
|
const sessionInfo = supa.getSession()
|
||||||
if (sessionInfo != null && sessionInfo.user != null) {
|
if (sessionInfo != null && sessionInfo.user != null) {
|
||||||
|
// 生产模式或主动进入登录页时,若已登录则尝试跳转回原页面或首页
|
||||||
const pages = getCurrentPages() as any[]
|
const pages = getCurrentPages() as any[]
|
||||||
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||||
const opts = currentPage?.options as any
|
const opts = currentPage?.options as any
|
||||||
const redirect = opts?.redirect as string | null
|
const redirect = opts?.redirect as string | null
|
||||||
|
|
||||||
|
// 注意:IS_TEST_MODE 仅在 boot 页禁止自动跳转,在登录页若已有 Session,建议还是允许其进入首页
|
||||||
if (redirect != null && redirect.length > 0) {
|
if (redirect != null && redirect.length > 0) {
|
||||||
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
||||||
} else {
|
} else if (!IS_TEST_MODE) {
|
||||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
// 非测试模式下,自动跳转首页
|
||||||
|
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -277,6 +282,7 @@ const handleLogin = async () => {
|
|||||||
class_id: ''
|
class_id: ''
|
||||||
} as UserProfile
|
} as UserProfile
|
||||||
setUserProfile(adminProfile)
|
setUserProfile(adminProfile)
|
||||||
|
uni.setStorageSync('user_id', 'admin')
|
||||||
|
|
||||||
uni.showToast({ title: '管理员登录成功(测试模式)', icon: 'success' })
|
uni.showToast({ title: '管理员登录成功(测试模式)', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -358,23 +364,53 @@ const handleLogin = async () => {
|
|||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
uni.setStorageSync('user_id', uid)
|
uni.setStorageSync('user_id', uid)
|
||||||
console.log('用户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 {
|
||||||
|
const uidStored = uni.getStorageSync('user_id') || null
|
||||||
|
const currentUid = uidStored || (currentSession.user ? currentSession.user.getString('id') : null)
|
||||||
|
await AkReq.request({
|
||||||
|
url: `${PUSH_SERVER_URL}/api/v1/push/register`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { cid, platform: 'android', user_id: currentUid },
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
console.log('CID 已上报后台:', cid, 'user_id:', currentUid)
|
||||||
|
} 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' })
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
if (!IS_TEST_MODE) {
|
// 登录成功后的跳转不应受 IS_TEST_MODE 限制,否则用户点击登录后无反馈
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const pages = getCurrentPages() as any[]
|
const pages = getCurrentPages() as any[]
|
||||||
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||||
const opts = currentPage?.options as any
|
const opts = currentPage?.options as any
|
||||||
const redirect = opts?.redirect as string | null
|
const redirect = opts?.redirect as string | null
|
||||||
if (redirect != null && redirect.length > 0) {
|
if (redirect != null && redirect.length > 0) {
|
||||||
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
||||||
} else {
|
} else {
|
||||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('登录错误:', err)
|
console.error('登录错误:', err)
|
||||||
let msg = '登录失败,请重试'
|
let msg = '登录失败,请重试'
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import { ensureUserProfile } from '@/utils/sapi.uts'
|
import { ensureUserProfile } from '@/utils/sapi.uts'
|
||||||
|
import { AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||||
|
import { PUSH_SERVER_URL } from '@/ak/config.uts'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const email = ref<string>('')
|
const email = ref<string>('')
|
||||||
@@ -293,6 +295,33 @@
|
|||||||
console.log('ℹ️ 需要邮箱验证,验证后登录时会自动创建用户资料')
|
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 {
|
||||||
|
const uidStored = uni.getStorageSync('user_id') || null
|
||||||
|
const currentSession = supa.getSession()
|
||||||
|
const currentUid = uidStored || (currentSession && currentSession.user ? currentSession.user.getString('id') : null)
|
||||||
|
await AkReq.request({
|
||||||
|
url: `${PUSH_SERVER_URL}/api/v1/push/register`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { cid, platform: 'android', user_id: currentUid },
|
||||||
|
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({
|
uni.showToast({
|
||||||
title: '注册成功',
|
title: '注册成功',
|
||||||
icon: 'success'
|
icon: 'success'
|
||||||
|
|||||||
14
server/config.json
Normal file
14
server/config.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"SUPA_URL": "http://192.168.1.62:18000",
|
||||||
|
"SUPA_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzM2MjExNDMsImV4cCI6MTkzMTMwMTE0M30.zahVzrUCUkB436SHYyM2muGL3Lg_aocJJgWv1t6PpKg",
|
||||||
|
"CLOUD_FUNC_URL": "https://env-00jy5x5oy9zd.dev-hz.cloudbasefunction.cn/test",
|
||||||
|
"ENABLE_CONSUMER": "true",
|
||||||
|
"CONSUMER_POLL_MS": "2000",
|
||||||
|
"PUSH_TOKEN": "",
|
||||||
|
"MAX_RETRIES": "5",
|
||||||
|
"RETRY_INITIAL_MS": "5000",
|
||||||
|
"RETRY_FACTOR": "2",
|
||||||
|
"RETRY_MAX_MS": "3600000",
|
||||||
|
"PUSH_SERVER_PORT": "7301",
|
||||||
|
"WEBHOOK_PORT": "7201"
|
||||||
|
}
|
||||||
13
server/config.json.example
Normal file
13
server/config.json.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"SUPA_URL": "https://your-supabase.example",
|
||||||
|
"SUPA_KEY": "your-service-role-key",
|
||||||
|
"CLOUD_FUNC_URL": "https://your-cloudfunc.example/handle",
|
||||||
|
"ENABLE_CONSUMER": "true",
|
||||||
|
"CONSUMER_POLL_MS": "2000",
|
||||||
|
"PUSH_TOKEN": "optional-push-token",
|
||||||
|
"MAX_RETRIES": "5",
|
||||||
|
"RETRY_INITIAL_MS": "5000",
|
||||||
|
"RETRY_FACTOR": "2",
|
||||||
|
"RETRY_MAX_MS": "3600000",
|
||||||
|
"PORT": "7301"
|
||||||
|
}
|
||||||
18
server/data/push_devices.json
Normal file
18
server/data/push_devices.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"cid": "CID_TEST_001",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"platform": "android",
|
||||||
|
"created_at": "2026-02-24T03:48:20.907Z",
|
||||||
|
"updated_at": "2026-02-24T03:48:20.907Z",
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cid": "你的CID",
|
||||||
|
"user_id": "123",
|
||||||
|
"platform": "android",
|
||||||
|
"created_at": "2026-02-25T13:28:58.901Z",
|
||||||
|
"updated_at": "2026-02-25T13:28:58.901Z",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
37
server/insert_supa.js
Normal file
37
server/insert_supa.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const fetch = require('node-fetch')
|
||||||
|
|
||||||
|
const SUPA = process.env.SUPA_URL || 'http://192.168.1.62:18000'
|
||||||
|
const KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
cid: 'CID_TEST_001',
|
||||||
|
user_id: 'a8e3a568-fc1f-4237-bcc5-5722e2fca0a3',
|
||||||
|
platform: 'android',
|
||||||
|
active: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const url = `${SUPA.replace(/\/$/, '')}/rest/v1/push_devices`
|
||||||
|
console.log('POST', url)
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: KEY,
|
||||||
|
Authorization: `Bearer ${KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
console.log('status', resp.status)
|
||||||
|
const txt = await resp.text()
|
||||||
|
console.log('body:', txt)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('insert error', e && e.message ? e.message : e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})()
|
||||||
68
server/load-config.js
Normal file
68
server/load-config.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function loadJsonConfig(filePath) {
|
||||||
|
try {
|
||||||
|
const txt = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const obj = JSON.parse(txt)
|
||||||
|
Object.keys(obj).forEach(k => {
|
||||||
|
if (process.env[k] === undefined) process.env[k] = String(obj[k])
|
||||||
|
})
|
||||||
|
console.log('Loaded config from', filePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDotEnv(filePath) {
|
||||||
|
try {
|
||||||
|
const txt = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const lines = txt.split(/\r?\n/)
|
||||||
|
for (const line of lines) {
|
||||||
|
const l = line.trim()
|
||||||
|
if (!l || l.startsWith('#')) continue
|
||||||
|
const idx = l.indexOf('=')
|
||||||
|
if (idx === -1) continue
|
||||||
|
const key = l.slice(0, idx).trim()
|
||||||
|
let val = l.slice(idx + 1).trim()
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = val
|
||||||
|
}
|
||||||
|
console.log('Loaded .env from', filePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precedence (higher wins because we only fill undefined keys):
|
||||||
|
// 1) real environment variables
|
||||||
|
// 2) CONFIG_FILE / CONFIG_PATH (explicit)
|
||||||
|
// 3) .env (local secrets)
|
||||||
|
// 4) config.json (repo/local config)
|
||||||
|
// 5) config.json.example (defaults)
|
||||||
|
const explicitPath = process.env.CONFIG_FILE || process.env.CONFIG_PATH
|
||||||
|
const candidates = [
|
||||||
|
explicitPath ? path.resolve(explicitPath) : null,
|
||||||
|
path.join(__dirname, '.env'),
|
||||||
|
path.join(__dirname, 'config.json'),
|
||||||
|
path.join(__dirname, 'config.json.example')
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
let loadedAny = false
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (!fs.existsSync(p)) continue
|
||||||
|
const ok = p.endsWith('.env') ? loadDotEnv(p)
|
||||||
|
: p.endsWith('.json') ? loadJsonConfig(p)
|
||||||
|
: false
|
||||||
|
if (ok) loadedAny = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadedAny) {
|
||||||
|
// no config found; silent
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {}
|
||||||
280
server/notify-worker.js
Normal file
280
server/notify-worker.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// notify-worker.js
|
||||||
|
// 常驻队列消费者:从 notify_queue 读取待处理事件,写入 express_notifications。
|
||||||
|
// push-server consumer 将继续轮询 express_notifications 并调用 CLOUD_FUNC_URL 实际下发。
|
||||||
|
//
|
||||||
|
// 配置加载优先级:
|
||||||
|
// 1) 系统环境变量
|
||||||
|
// 2) CONFIG_FILE/CONFIG_PATH
|
||||||
|
// 3) 同目录 notify-worker.config.json
|
||||||
|
// 4) server/.env / server/config.json via server/load-config.js
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const localConfigPath = path.join(__dirname, 'notify-worker.config.json')
|
||||||
|
if (!process.env.CONFIG_FILE && !process.env.CONFIG_PATH && fs.existsSync(localConfigPath)) {
|
||||||
|
process.env.CONFIG_FILE = localConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
require('./load-config')
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const fetchImpl = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => {
|
||||||
|
try {
|
||||||
|
return require('node-fetch')
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.")
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
|
||||||
|
const SUPA_URL = (process.env.SUPA_URL || process.env.SUPA_URL_OVERRIDE || '').replace(/\/$/, '')
|
||||||
|
// Prefer service role key when present (server-side), to avoid RLS issues.
|
||||||
|
const SUPA_KEY = process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY || ''
|
||||||
|
// notify-worker needs to read ml_orders which is often protected by RLS.
|
||||||
|
// To avoid changing behavior of other services, allow a worker-specific toggle.
|
||||||
|
const SUPA_USE_BEARER = (
|
||||||
|
process.env.NOTIFY_WORKER_SUPA_USE_BEARER ||
|
||||||
|
process.env.WORKER_SUPA_USE_BEARER ||
|
||||||
|
process.env.SUPA_USE_BEARER ||
|
||||||
|
'false'
|
||||||
|
) === 'true'
|
||||||
|
|
||||||
|
const POLL_MS = Number(process.env.NOTIFY_WORKER_POLL_MS || process.env.WORKER_POLL_MS || 2000)
|
||||||
|
const BATCH_SIZE = Number(process.env.NOTIFY_WORKER_BATCH_SIZE || process.env.WORKER_BATCH_SIZE || 20)
|
||||||
|
const RUN_ONCE = (process.env.RUN_ONCE === 'true' || process.env.RUN_ONCE === '1')
|
||||||
|
|
||||||
|
function supaFetch(restPath, opts = {}, useBearer = false) {
|
||||||
|
const url = `${SUPA_URL}/rest/v1/${restPath}`
|
||||||
|
const headers = Object.assign({}, opts.headers || {}, {
|
||||||
|
apikey: SUPA_KEY,
|
||||||
|
Accept: 'application/json'
|
||||||
|
})
|
||||||
|
if (useBearer) headers.Authorization = `Bearer ${SUPA_KEY}`
|
||||||
|
|
||||||
|
if (process.env.DEBUG_SUPA) {
|
||||||
|
console.log(`[DEBUG] supaFetch: ${opts.method || 'GET'} ${url}`)
|
||||||
|
console.log(`[DEBUG] Headers: apikey=${SUPA_KEY ? SUPA_KEY.substring(0, 10) : 'MISSING'}... Authorization=${headers.Authorization ? 'PRESENT' : 'NONE'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchImpl(url, Object.assign({}, opts, { headers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Hex(input) {
|
||||||
|
return crypto.createHash('sha256').update(String(input)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageId({ aud, waybill_id, dedupe_key }) {
|
||||||
|
const h = sha256Hex(`${aud}|${waybill_id}|${dedupe_key}`)
|
||||||
|
return `evt_${aud}_${h.slice(0, 32)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPendingQueue(limit) {
|
||||||
|
const q = `notify_queue?processed_at=is.null&order=created_at.asc&limit=${encodeURIComponent(String(limit))}`
|
||||||
|
const resp = await supaFetch(q, { method: 'GET' }, false)
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`fetch notify_queue failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWaybill(waybillId) {
|
||||||
|
const resp = await supaFetch(
|
||||||
|
`platform_express_waybills?id=eq.${encodeURIComponent(waybillId)}&select=id,order_id,order_no,carrier,tracking_no`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`fetch waybill failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const rows = await resp.json()
|
||||||
|
return rows && rows[0] ? rows[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrderById(orderId) {
|
||||||
|
const resp = await supaFetch(
|
||||||
|
`ml_orders?id=eq.${encodeURIComponent(orderId)}&select=id,order_no,user_id,merchant_id`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
SUPA_USE_BEARER
|
||||||
|
)
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`fetch order (id) failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const rows = await resp.json()
|
||||||
|
return rows && rows[0] ? rows[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrderByNo(orderNo) {
|
||||||
|
const resp = await supaFetch(
|
||||||
|
`ml_orders?order_no=eq.${encodeURIComponent(orderNo)}&select=id,order_no,user_id,merchant_id`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
SUPA_USE_BEARER
|
||||||
|
)
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`fetch order (order_no) failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const rows = await resp.json()
|
||||||
|
return rows && rows[0] ? rows[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertExpressNotification(body) {
|
||||||
|
const resp = await supaFetch('express_notifications?on_conflict=message_id', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation,resolution=merge-duplicates'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`insert express_notifications failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await resp.json().catch(() => null)
|
||||||
|
return Array.isArray(rows) ? rows[0] : rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markQueueProcessed(id, patch) {
|
||||||
|
const resp = await supaFetch(`notify_queue?id=eq.${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(Object.assign({}, patch, { processed_at: new Date().toISOString() }))
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`patch notify_queue failed: HTTP ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOne(row) {
|
||||||
|
const waybillId = row.waybill_id
|
||||||
|
if (!waybillId) {
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'missing waybill_id' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const waybill = await fetchWaybill(waybillId)
|
||||||
|
if (!waybill) {
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'waybill not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let order = null
|
||||||
|
if (waybill.order_id) order = await fetchOrderById(waybill.order_id)
|
||||||
|
if (!order && waybill.order_no) order = await fetchOrderByNo(waybill.order_no)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'order not found for waybill' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = []
|
||||||
|
if (order.user_id) recipients.push({ aud: 'user', recipient_id: order.user_id })
|
||||||
|
if (order.merchant_id) recipients.push({ aud: 'merchant', recipient_id: order.merchant_id })
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'skipped', last_error: 'no recipient_id resolved from order' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
biz: 'express',
|
||||||
|
order_no: order.order_no || waybill.order_no || null,
|
||||||
|
order_id: order.id || waybill.order_id || null,
|
||||||
|
waybill_id: waybill.id,
|
||||||
|
carrier: waybill.carrier || row.carrier || null,
|
||||||
|
tracking_no: waybill.tracking_no || row.tracking_no || null,
|
||||||
|
status_code: row.status_code,
|
||||||
|
event_time: row.event_time,
|
||||||
|
event_id: row.event_id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个受众写入一条通知(message_id 按 aud 区分,确保幂等)
|
||||||
|
for (const r of recipients) {
|
||||||
|
const message_id = buildMessageId({ aud: r.aud, waybill_id: waybill.id, dedupe_key: row.dedupe_key })
|
||||||
|
const dedupe_key = `${waybill.id}|${r.aud}|${row.dedupe_key}`.slice(0, 256)
|
||||||
|
|
||||||
|
await upsertExpressNotification({
|
||||||
|
aud: r.aud,
|
||||||
|
recipient_id: r.recipient_id,
|
||||||
|
order_id: order.id || null,
|
||||||
|
waybill_id: waybill.id,
|
||||||
|
tracking_no: waybill.tracking_no || null,
|
||||||
|
carrier: waybill.carrier || null,
|
||||||
|
message_id,
|
||||||
|
event_text_safe: row.event_text || null,
|
||||||
|
status_code: row.status_code,
|
||||||
|
event_time: row.event_time || null,
|
||||||
|
payload,
|
||||||
|
dedupe_key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'queued', last_error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loopOnce() {
|
||||||
|
const rows = await fetchPendingQueue(BATCH_SIZE)
|
||||||
|
if (!rows || rows.length === 0) return { processed: 0 }
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
await processOne(row)
|
||||||
|
processed += 1
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e && e.message) ? e.message : String(e)
|
||||||
|
try {
|
||||||
|
await markQueueProcessed(row.id, { process_status: 'failed', last_error: msg.substring(0, 2000) })
|
||||||
|
} catch (patchErr) {
|
||||||
|
console.warn('failed to mark queue row failed:', patchErr)
|
||||||
|
}
|
||||||
|
console.warn('processOne failed:', msg)
|
||||||
|
processed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!SUPA_URL || !SUPA_KEY) {
|
||||||
|
console.error('SUPA_URL and SUPA_KEY must be set in env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('notify-worker starting...')
|
||||||
|
console.log('ENV: SUPA_URL=', SUPA_URL)
|
||||||
|
console.log('ENV: SUPA_USE_BEARER=', SUPA_USE_BEARER)
|
||||||
|
console.log('ENV: POLL_MS=', POLL_MS, 'BATCH_SIZE=', BATCH_SIZE, 'RUN_ONCE=', RUN_ONCE)
|
||||||
|
|
||||||
|
if (RUN_ONCE) {
|
||||||
|
const r = await loopOnce()
|
||||||
|
console.log('notify-worker done (run once):', r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常驻轮询
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const r = await loopOnce()
|
||||||
|
if (r.processed > 0) console.log('notify-worker processed:', r.processed)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('notify-worker loop error:', e && e.message ? e.message : e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('notify-worker fatal:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
2386
server/package-lock.json
generated
Normal file
2386
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/package.json
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"worker": "node notify-worker.js",
|
||||||
|
"dev": "nodemon --watch . --exec \"node --inspect=0.0.0.0:9229 push-server.js\"",
|
||||||
|
"dev:worker": "nodemon --watch . --exec \"node --inspect=0.0.0.0:9230 notify-worker.js\"",
|
||||||
|
"dev:pnpm": "pnpm -w --parallel --filter ./server --filter ./server -- \"node --inspect=0.0.0.0:9229 push-server.js\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/probe_supa.js
Normal file
28
server/probe_supa.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const fetch = require('node-fetch')
|
||||||
|
|
||||||
|
const SUPA = process.env.SUPA_URL || process.env.SUPA || 'http://192.168.1.62:18000'
|
||||||
|
// 支持两种环境变量名:SUPA_KEY 或 SERVICE_ROLE_KEY(自托管 .env 里常见)
|
||||||
|
const KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtMSIsImlhdCI6MTc3MTk4OTAwNywiZXhwIjoxODAzNTI1MDA3fQ.TBZeJIYEGdcKMMXAAJAka-09c4hO8_e_wyW8V5tkJ2Y';
|
||||||
|
const USER = 'a8e3a568-fc1f-4237-bcc5-5722e2fca0a3';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const url = `${SUPA.replace(/\/$/, '')}/rest/v1/push_devices?user_id=eq.${encodeURIComponent(USER)}`
|
||||||
|
console.log('GET', url)
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
apikey: KEY,
|
||||||
|
Authorization: `Bearer ${KEY}`,
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
console.log('status', resp.status)
|
||||||
|
const txt = await resp.text()
|
||||||
|
console.log('body:', txt)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('probe error', e && e.message ? e.message : e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})()
|
||||||
612
server/push-server.js
Normal file
612
server/push-server.js
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
try {
|
||||||
|
require('./load-config')
|
||||||
|
} catch (e) {
|
||||||
|
// no-op if no config file
|
||||||
|
}
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const bodyParser = require('body-parser')
|
||||||
|
const cors = require('cors')
|
||||||
|
const fs = require('fs').promises
|
||||||
|
const path = require('path')
|
||||||
|
const fetchImpl = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : (() => {
|
||||||
|
try {
|
||||||
|
return require('node-fetch')
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("No fetch implementation found. Use Node.js 18+ or install 'node-fetch'.")
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
|
||||||
|
// 支持服务专用端口,避免与 webhook-receiver 共用 server/config.json 时发生端口冲突
|
||||||
|
const PORT = process.env.PUSH_SERVER_PORT || process.env.PORT || 7301
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data')
|
||||||
|
const DEVICES_FILE = path.join(DATA_DIR, 'push_devices.json')
|
||||||
|
const SUPA_URL = process.env.SUPA_URL || ''
|
||||||
|
// 支持两种环境变量名:SUPA_KEY(旧)和 SERVICE_ROLE_KEY(常用于自托管 .env)
|
||||||
|
// Prefer service role key when present (server-side), to avoid RLS issues.
|
||||||
|
const SUPA_KEY = process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY || ''
|
||||||
|
const SUPA_SCHEMA = process.env.SUPA_SCHEMA || 'public'
|
||||||
|
|
||||||
|
// Consumer 配置
|
||||||
|
const CONSUMER_ENABLED = (process.env.ENABLE_CONSUMER === 'true') || (process.env.CONSUMER_ENABLED === 'true')
|
||||||
|
const CONSUMER_POLL_MS = parseInt(process.env.CONSUMER_POLL_MS || process.env.CONSUMER_POLL_INTERVAL_MS || '2000', 10)
|
||||||
|
const CLOUD_FUNC_URL = process.env.CLOUD_FUNC_URL || ''
|
||||||
|
const PUSH_TOKEN = process.env.PUSH_TOKEN || ''
|
||||||
|
// Retry/backoff config
|
||||||
|
const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10)
|
||||||
|
const RETRY_INITIAL_MS = parseInt(process.env.RETRY_INITIAL_MS || '5000', 10)
|
||||||
|
const RETRY_FACTOR = parseInt(process.env.RETRY_FACTOR || '2', 10)
|
||||||
|
const RETRY_MAX_MS = parseInt(process.env.RETRY_MAX_MS || '3600000', 10) // 1 hour
|
||||||
|
|
||||||
|
let warnedMissingSendStatus = false
|
||||||
|
|
||||||
|
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 supaFetch(path, opts = {}) {
|
||||||
|
const url = `${SUPA_URL.replace(/\/$/, '')}/rest/v1/${path}`
|
||||||
|
// 默认只发送 apikey。
|
||||||
|
// 只有在显式要求 Bearer(环境变量 SUPA_USE_BEARER=true)时,才添加 Authorization 头。
|
||||||
|
// 注意:在一些自托管 Supabase/Kong 场景中,apikey 可用于 Kong 的 key-auth,
|
||||||
|
// 但 PostgREST 可能无法解码我们手动附加的 Bearer JWT(JWT_SECRET 不一致会触发 PGRST301)。
|
||||||
|
const headers = Object.assign({}, opts.headers || {}, {
|
||||||
|
apikey: SUPA_KEY,
|
||||||
|
Accept: 'application/json'
|
||||||
|
})
|
||||||
|
const sendBearer = (process.env.SUPA_USE_BEARER === 'true')
|
||||||
|
if (sendBearer) headers.Authorization = `Bearer ${SUPA_KEY}`
|
||||||
|
try {
|
||||||
|
console.log('supaFetch ->', url)
|
||||||
|
const resp = await fetchImpl(url, Object.assign({}, opts, { headers }))
|
||||||
|
if (!resp.ok) {
|
||||||
|
let txt = ''
|
||||||
|
try { txt = await resp.text() } catch (e) { txt = `<unable to read body: ${e}>` }
|
||||||
|
console.warn('supaFetch failed', resp.status, url, txt)
|
||||||
|
} else {
|
||||||
|
// optional: peek at small JSON responses for debugging
|
||||||
|
try {
|
||||||
|
const clone = resp.clone()
|
||||||
|
const maybeText = await clone.text()
|
||||||
|
if (maybeText && maybeText.length < 2000) console.log('supaFetch response preview:', maybeText)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore preview errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('supaFetch exception', e, url)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud-function-only:
|
||||||
|
// UNI-PUSH adapter has been disabled. Push delivery should be implemented inside the cloud function.
|
||||||
|
async function sendToUniPush() {
|
||||||
|
throw new Error('UNI-PUSH adapter disabled (cloud-function-only mode)')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDevicesFromSupabase({ user_id, active } = {}) {
|
||||||
|
let q = 'push_devices'
|
||||||
|
const params = []
|
||||||
|
if (user_id) params.push(`user_id=eq.${encodeURIComponent(user_id)}`)
|
||||||
|
if (active != null) params.push(`is_active=eq.${encodeURIComponent(active ? 'true' : 'false')}`)
|
||||||
|
const suffix = params.length ? `?${params.join('&')}` : ''
|
||||||
|
const resp = await supaFetch(`${q}${suffix}`, { method: 'GET' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text()
|
||||||
|
throw new Error(`supabase get devices failed ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const data = await resp.json()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMerchantDevicesFromSupabase({ merchant_id, active } = {}) {
|
||||||
|
let q = 'push_devices'
|
||||||
|
const params = []
|
||||||
|
if (merchant_id) params.push(`merchant_id=eq.${encodeURIComponent(merchant_id)}`)
|
||||||
|
if (active != null) params.push(`is_active=eq.${encodeURIComponent(active ? 'true' : 'false')}`)
|
||||||
|
const suffix = params.length ? `?${params.join('&')}` : ''
|
||||||
|
const resp = await supaFetch(`${q}${suffix}`, { method: 'GET' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text()
|
||||||
|
throw new Error(`supabase get merchant devices failed ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const data = await resp.json()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await ensureDataDir()
|
||||||
|
const app = express()
|
||||||
|
app.use(cors())
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
|
||||||
|
// 部署路由与简单鉴权(若设置了 DEPLOY_BEARER 或 DEPLOY_TOKEN 环境变量)
|
||||||
|
try {
|
||||||
|
const deployRouter = require('./routes/deploy')
|
||||||
|
const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
|
||||||
|
app.use('/api/v1', (req, res, next) => {
|
||||||
|
if (req.path === '/deploy-cloudfunc' && DEPLOY_BEARER) {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (auth !== `Bearer ${DEPLOY_BEARER}`) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}, deployRouter)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('deploy route not mounted (missing file?):', e && e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }))
|
||||||
|
|
||||||
|
// ---- Consumer: 从 express_notifications 拉取 pending 记录并调用云函数 ----
|
||||||
|
async function fetchPendingNotifications(limit = 5) {
|
||||||
|
// 只在配置了 Supabase REST 时工作
|
||||||
|
if (!SUPA_URL || !SUPA_KEY) return []
|
||||||
|
try {
|
||||||
|
// 查询:
|
||||||
|
// - pending: send_status IS NULL
|
||||||
|
// - retrying: send_status = 'retrying' 且 (next_attempt_at IS NULL 或 next_attempt_at <= now)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const q = `express_notifications?or=(send_status.is.null,and(send_status.eq.retrying,or(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})))&order=created_at.asc&limit=${limit}`
|
||||||
|
const resp = await supaFetch(q, { method: 'GET' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (!warnedMissingSendStatus && resp.status === 400) {
|
||||||
|
warnedMissingSendStatus = true
|
||||||
|
console.warn('Consumer query failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const data = await resp.json().catch(() => [])
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchPendingNotifications error', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimNotification(id) {
|
||||||
|
try {
|
||||||
|
const body = { send_status: 'processing', updated_at: new Date().toISOString() }
|
||||||
|
// 仅当当前仍为 pending 或 retrying 且到期时,才抢占。
|
||||||
|
// 为了避免在 PATCH 中使用复杂 or= 逻辑树导致匹配失败,这里拆成两次尝试。
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const attempts = [
|
||||||
|
// pending: send_status IS NULL
|
||||||
|
`express_notifications?id=eq.${encodeURIComponent(id)}&send_status=is.null`,
|
||||||
|
// retrying: send_status='retrying' and next_attempt_at is null or <= now
|
||||||
|
`express_notifications?id=eq.${encodeURIComponent(id)}&send_status=eq.retrying&or=(next_attempt_at.is.null,next_attempt_at.lte.${encodeURIComponent(now)})`
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const path of attempts) {
|
||||||
|
const resp = await supaFetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' }, body: JSON.stringify(body) })
|
||||||
|
if (!resp.ok && !warnedMissingSendStatus && resp.status === 400) {
|
||||||
|
warnedMissingSendStatus = true
|
||||||
|
console.warn('Consumer claim failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||||
|
}
|
||||||
|
if (!resp.ok) continue
|
||||||
|
const j = await resp.json().catch(() => null)
|
||||||
|
if (Array.isArray(j)) {
|
||||||
|
if (j.length > 0) return j[0]
|
||||||
|
// debug hint: matched 0 rows
|
||||||
|
try { console.log('claimNotification: updated 0 rows for', path) } catch (e) {}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (j && j.id) return j
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('claimNotification error', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNotificationStatus(id, status, note) {
|
||||||
|
try {
|
||||||
|
if (!id) return
|
||||||
|
const body = { send_status: String(status), updated_at: new Date().toISOString() }
|
||||||
|
if (note) body.last_error = String(note).substring(0, 2000)
|
||||||
|
const resp = await supaFetch(`express_notifications?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
if (!resp.ok && !warnedMissingSendStatus && resp.status === 400) {
|
||||||
|
warnedMissingSendStatus = true
|
||||||
|
console.warn('Consumer status update failed. If this mentions missing column "send_status", run migration: pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql')
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('updateNotificationStatus error', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeCloudFuncForCid(funcUrl, token, pushCid, title, content, payload) {
|
||||||
|
try {
|
||||||
|
const body = { token: token || PUSH_TOKEN || null, push_clientid: pushCid, title: title || '', content: content || '', payload: payload || {} }
|
||||||
|
const resp = await fetchImpl(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
const txt = await resp.text().catch(() => '')
|
||||||
|
let json
|
||||||
|
try { json = JSON.parse(txt) } catch (e) { json = { statusText: txt } }
|
||||||
|
// Treat explicit business-level failures as errors even when HTTP is 2xx.
|
||||||
|
// Many cloud functions return `{ ok: false, ... }` with HTTP 200.
|
||||||
|
const businessOk = (() => {
|
||||||
|
if (!json || typeof json !== 'object') return true
|
||||||
|
if (json.ok === false) return false
|
||||||
|
// uniCloud/uni-push demo often returns { errCode: 0|..., errMsg: '...' }
|
||||||
|
if (typeof json.errCode === 'number') return json.errCode === 0
|
||||||
|
if (typeof json.errCode === 'string' && json.errCode.trim() !== '') return json.errCode === '0'
|
||||||
|
return true
|
||||||
|
})()
|
||||||
|
return { ok: resp.ok && businessOk, httpOk: resp.ok, status: resp.status, body: json }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNotificationRecord(rec) {
|
||||||
|
if (!rec || !rec.id) return
|
||||||
|
// claim the record (set processing) to avoid races
|
||||||
|
const claimed = await claimNotification(rec.id)
|
||||||
|
if (!claimed) return // someone else claimed or failed
|
||||||
|
|
||||||
|
const aud = claimed.aud
|
||||||
|
const recipient_id = claimed.recipient_id
|
||||||
|
const notification = claimed.payload && claimed.payload.notification ? claimed.payload.notification : (claimed.event_text_safe ? { title: claimed.event_text_safe, body: '' } : {})
|
||||||
|
const payload = claimed.payload || {}
|
||||||
|
|
||||||
|
// Some push providers (and client notification renderers) may not display a system notification
|
||||||
|
// when content/body is empty, even if the push API returns success.
|
||||||
|
const titleText = (notification && (notification.title || notification.event_text || notification.text)) || claimed.event_text_safe || ''
|
||||||
|
const bodyText = (notification && (notification.body || notification.content)) || ''
|
||||||
|
const contentText = bodyText || titleText || ''
|
||||||
|
|
||||||
|
// build targets similar to /api/v1/notifications
|
||||||
|
let targets = []
|
||||||
|
try {
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
if (aud === 'user') {
|
||||||
|
const devices = await getDevicesFromSupabase({ user_id: recipient_id, active: true })
|
||||||
|
targets = devices.map(d => d.cid)
|
||||||
|
} else if (aud === 'merchant') {
|
||||||
|
const resp = await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(recipient_id)}&is_active=eq.true`, { method: 'GET' })
|
||||||
|
if (resp.ok) {
|
||||||
|
const devs = await resp.json().catch(() => [])
|
||||||
|
targets = devs.map(d => d.cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('processNotificationRecord fetch devices failed', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targets || targets.length === 0) {
|
||||||
|
await updateNotificationStatus(claimed.id, 'no-targets', 'no active devices')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud-function-only: always POST per cid to CLOUD_FUNC_URL
|
||||||
|
let allOk = true
|
||||||
|
let lastNote = ''
|
||||||
|
if (CLOUD_FUNC_URL) {
|
||||||
|
const calls = await Promise.all(targets.map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, titleText, contentText, payload)))
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!c.ok) {
|
||||||
|
allOk = false
|
||||||
|
const err = c.error ? String(c.error) : ''
|
||||||
|
const bodyStr = c.body ? JSON.stringify(c.body) : ''
|
||||||
|
lastNote = `cloudfunc failed: status=${c.status || ''} httpOk=${c.httpOk === true} ${err} ${bodyStr}`.trim().substring(0, 1000)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allOk = false
|
||||||
|
lastNote = 'no CLOUD_FUNC_URL configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allOk) {
|
||||||
|
await updateNotificationStatus(claimed.id, 'success', null)
|
||||||
|
} else {
|
||||||
|
// failure -> increment retry_count, set last_error and next_attempt_at or mark failed
|
||||||
|
const currentRetry = parseInt(claimed.retry_count || 0, 10)
|
||||||
|
const nextRetry = currentRetry + 1
|
||||||
|
if (nextRetry >= MAX_RETRIES) {
|
||||||
|
// mark failed
|
||||||
|
try {
|
||||||
|
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ send_status: 'failed', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), updated_at: new Date().toISOString() }) })
|
||||||
|
} catch (e) { console.warn('mark failed error', e) }
|
||||||
|
} else {
|
||||||
|
// compute exponential backoff
|
||||||
|
let delay = RETRY_INITIAL_MS * Math.pow(RETRY_FACTOR, currentRetry)
|
||||||
|
if (delay > RETRY_MAX_MS) delay = RETRY_MAX_MS
|
||||||
|
const nextAt = new Date(Date.now() + delay).toISOString()
|
||||||
|
try {
|
||||||
|
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ send_status: 'retrying', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), next_attempt_at: nextAt, updated_at: new Date().toISOString() }) })
|
||||||
|
} catch (e) { console.warn('schedule retry error', e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumerOnce() {
|
||||||
|
try {
|
||||||
|
const pending = await fetchPendingNotifications(5)
|
||||||
|
if (!pending || pending.length === 0) return
|
||||||
|
for (const rec of pending) {
|
||||||
|
try { await processNotificationRecord(rec) } catch (e) { console.warn('consumer process error', e) }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('consumerOnce error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONSUMER_ENABLED) {
|
||||||
|
if (!SUPA_URL || !SUPA_KEY) console.warn('Consumer enabled but SUPA_URL/SUPA_KEY not configured; consumer will not run against Supabase.')
|
||||||
|
else console.log('Notification consumer enabled, poll interval(ms)=', CONSUMER_POLL_MS)
|
||||||
|
setInterval(consumerOnce, CONSUMER_POLL_MS)
|
||||||
|
}
|
||||||
|
// ---- end consumer ----
|
||||||
|
|
||||||
|
// 注册或更新设备
|
||||||
|
app.post('/api/v1/push/register', async (req, res) => {
|
||||||
|
const { cid, user_id, merchant_id, platform, appid, registration_source } = 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.merchant_id = merchant_id ?? found.merchant_id
|
||||||
|
found.platform = platform ?? found.platform
|
||||||
|
found.appid = appid ?? found.appid
|
||||||
|
found.registration_source = registration_source ?? found.registration_source
|
||||||
|
found.updated_at = now
|
||||||
|
found.active = true
|
||||||
|
} else {
|
||||||
|
found = {
|
||||||
|
cid,
|
||||||
|
user_id: user_id ?? null,
|
||||||
|
merchant_id: merchant_id ?? null,
|
||||||
|
platform: platform ?? null,
|
||||||
|
appid: appid ?? 'default',
|
||||||
|
registration_source: registration_source ?? null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
active: true
|
||||||
|
}
|
||||||
|
devices.push(found)
|
||||||
|
}
|
||||||
|
await writeDevices(devices)
|
||||||
|
// 如果配置了 Supabase,则尝试写入 supabase 表(异步记录错误)
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
cid: found.cid,
|
||||||
|
user_id: found.user_id,
|
||||||
|
merchant_id: found.merchant_id,
|
||||||
|
platform: found.platform,
|
||||||
|
appid: found.appid || 'default',
|
||||||
|
is_active: true,
|
||||||
|
last_seen_at: found.updated_at
|
||||||
|
}
|
||||||
|
if (found.registration_source) body.registration_source = found.registration_source
|
||||||
|
const resp = await supaFetch(`push_devices?on_conflict=appid,cid`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text()
|
||||||
|
console.warn('Supabase upsert push_device failed:', resp.status, txt)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Supabase upsert exception:', e)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ok: true, cid, user_id: found.user_id || null, merchant_id: found.merchant_id || null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注销设备(可选移除或置为 inactive)
|
||||||
|
app.post('/api/v1/push/unregister', async (req, res) => {
|
||||||
|
const { cid, user_id, merchant_id } = req.body || {}
|
||||||
|
if (!cid && !user_id && !merchant_id) return res.status(400).json({ error: 'cid or user_id or merchant_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)
|
||||||
|
} else if (merchant_id) {
|
||||||
|
devices = devices.map(d => d.merchant_id === merchant_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||||
|
}
|
||||||
|
await writeDevices(devices)
|
||||||
|
// 同步到 Supabase(如果可用)
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
if (cid) {
|
||||||
|
await supaFetch(`push_devices?cid=eq.${encodeURIComponent(cid)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ is_active: false, updated_at: now })
|
||||||
|
})
|
||||||
|
} else if (user_id) {
|
||||||
|
await supaFetch(`push_devices?user_id=eq.${encodeURIComponent(user_id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ is_active: false, updated_at: now })
|
||||||
|
})
|
||||||
|
} else if (merchant_id) {
|
||||||
|
await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(merchant_id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ is_active: false, updated_at: now })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Supabase unregister exception:', e)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列出设备
|
||||||
|
app.get('/api/v1/push/devices', async (req, res) => {
|
||||||
|
const { user_id, merchant_id, active } = req.query
|
||||||
|
try {
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
let devices = []
|
||||||
|
if (merchant_id) devices = await getMerchantDevicesFromSupabase({ merchant_id, active: active == null ? undefined : (active === 'true') })
|
||||||
|
else devices = await getDevicesFromSupabase({ user_id, active: active == null ? undefined : (active === 'true') })
|
||||||
|
return res.json({ ok: true, total: devices.length, data: devices })
|
||||||
|
}
|
||||||
|
let devices = await readDevices()
|
||||||
|
if (user_id) devices = devices.filter(d => String(d.user_id) === String(user_id))
|
||||||
|
if (merchant_id) devices = devices.filter(d => String(d.merchant_id) === String(merchant_id))
|
||||||
|
if (active != null) devices = devices.filter(d => String(!!d.active) === String(active === 'true'))
|
||||||
|
res.json({ ok: true, total: devices.length, data: devices })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('list devices failed:', e)
|
||||||
|
res.status(500).json({ ok: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送推送(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) {
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
try {
|
||||||
|
const devices = await getDevicesFromSupabase({ user_id, active: true })
|
||||||
|
targets = devices.map(d => d.cid)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Supabase query devices failed, falling back to local:', e)
|
||||||
|
const devices = await readDevices()
|
||||||
|
targets = devices.filter(d => String(d.user_id) === String(user_id) && d.active).map(d => d.cid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const devices = await readDevices()
|
||||||
|
targets = devices.filter(d => String(d.user_id) === String(user_id) && d.active).map(d => d.cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CLOUD_FUNC_URL) return res.status(500).json({ ok: false, error: 'CLOUD_FUNC_URL not configured' })
|
||||||
|
console.log('send handler (cloud function only) targets count:', targets.length)
|
||||||
|
const calls = await Promise.all((targets || []).map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, notification && notification.title, notification && (notification.body || notification.content), payload)))
|
||||||
|
const allOk = calls.every(c => c && c.ok)
|
||||||
|
return res.status(allOk ? 200 : 502).json({ ok: allOk, sent: targets.length, results: calls })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建通知并基于数据库记录推送(写入 express_notifications 并发送)
|
||||||
|
app.post('/api/v1/notifications', async (req, res) => {
|
||||||
|
const { aud, recipient_id, notification, payload, dedupe_key, message_id, order_id, waybill_id } = req.body || {}
|
||||||
|
if (!aud || !recipient_id) return res.status(400).json({ error: 'aud and recipient_id required' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 写入 express_notifications 表并返回记录(使用 return=representation)
|
||||||
|
const body = Object.assign({}, { aud, recipient_id, event_text_safe: (notification && notification.title) || null, payload: payload || null, dedupe_key: dedupe_key || null, message_id: message_id || null, order_id: order_id || null, waybill_id: waybill_id || null, event_time: new Date().toISOString() })
|
||||||
|
const insertResp = await supaFetch(`express_notifications?on_conflict=message_id`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (!insertResp.ok) {
|
||||||
|
const txt = await insertResp.text()
|
||||||
|
return res.status(500).json({ ok: false, error: `insert notification failed ${insertResp.status} ${txt}` })
|
||||||
|
}
|
||||||
|
const inserted = await insertResp.json()
|
||||||
|
const record = Array.isArray(inserted) ? inserted[0] : inserted
|
||||||
|
|
||||||
|
// 根据 aud 拉取目标设备
|
||||||
|
let targets = []
|
||||||
|
try {
|
||||||
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
|
if (aud === 'user') {
|
||||||
|
const devices = await getDevicesFromSupabase({ user_id: recipient_id, active: true })
|
||||||
|
targets = devices.map(d => d.cid)
|
||||||
|
} else if (aud === 'merchant') {
|
||||||
|
// 使用 merchant_id 字段查询
|
||||||
|
const resp = await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(recipient_id)}&is_active=eq.true`, { method: 'GET' })
|
||||||
|
if (resp.ok) {
|
||||||
|
const devices = await resp.json()
|
||||||
|
targets = devices.map(d => d.cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 本地文件回退
|
||||||
|
const devices = await readDevices()
|
||||||
|
if (aud === 'user') targets = devices.filter(d => String(d.user_id) === String(recipient_id) && d.active).map(d => d.cid)
|
||||||
|
else if (aud === 'merchant') targets = devices.filter(d => String(d.merchant_id) === String(recipient_id) && d.active).map(d => d.cid)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetch devices for notification failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud-function-only:
|
||||||
|
// - This endpoint writes to express_notifications.
|
||||||
|
// - Actual push delivery is performed by the consumer that polls express_notifications.
|
||||||
|
return res.json({ ok: true, queued: true, notification_id: record && record.id, targets: targets.length })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('create notification failed:', e)
|
||||||
|
return res.status(500).json({ ok: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Push server listening on http://0.0.0.0:${PORT}`)
|
||||||
|
console.log('ENV: CLOUD_FUNC_URL configured?', !!process.env.CLOUD_FUNC_URL)
|
||||||
|
console.log('ENV: ENABLE_CONSUMER=', process.env.ENABLE_CONSUMER)
|
||||||
|
// // 可选:在启动时自动运行部署脚本以打包并上传云函数
|
||||||
|
// try {
|
||||||
|
// const AUTO_DEPLOY = (process.env.AUTO_DEPLOY_ON_START === 'true' || process.env.AUTO_DEPLOY_ON_START === '1')
|
||||||
|
// const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
|
||||||
|
// if (AUTO_DEPLOY) {
|
||||||
|
// if (!DEPLOY_BEARER) {
|
||||||
|
// console.warn('AUTO_DEPLOY_ON_START enabled but no DEPLOY_BEARER found; skipping auto deploy for safety.')
|
||||||
|
// } else {
|
||||||
|
// const argsEnv = process.env.AUTO_DEPLOY_ARGS ? process.env.AUTO_DEPLOY_ARGS.split(' ') : []
|
||||||
|
// const deployScript = path.join(__dirname, 'tools', 'deploy-cloudfunc.js')
|
||||||
|
// const nodeArgs = [deployScript].concat(argsEnv)
|
||||||
|
// console.log('Auto-deploy: spawning node', nodeArgs.join(' '))
|
||||||
|
// const child = spawn(process.execPath, nodeArgs, { cwd: __dirname, env: Object.assign({}, process.env), stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
// child.stdout.on('data', d => console.log('[auto-deploy stdout]', d.toString().trim()))
|
||||||
|
// child.stderr.on('data', d => console.error('[auto-deploy stderr]', d.toString().trim()))
|
||||||
|
// child.on('exit', (code, signal) => console.log(`Auto-deploy process exited with code=${code} signal=${signal}`))
|
||||||
|
// child.on('error', e => console.error('Auto-deploy spawn error', e))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// console.warn('auto-deploy runtime error', e)
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch(e => {
|
||||||
|
console.error('启动失败:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
32
server/routes/deploy.js
Normal file
32
server/routes/deploy.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { deployCloudFunction } = require('../tools/deploy-cloudfunc-service');
|
||||||
|
|
||||||
|
// POST /api/v1/deploy-cloudfunc
|
||||||
|
// body: { uploadUrl, uploadToken, deployApi?, deployToken?, funcInvokeUrl?, pushToken?, testCid? }
|
||||||
|
router.post('/deploy-cloudfunc', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const options = {
|
||||||
|
uploadUrl: body.uploadUrl || process.env.CLOUD_UPLOAD_URL,
|
||||||
|
uploadToken: body.uploadToken || process.env.CLOUD_UPLOAD_TOKEN,
|
||||||
|
uniAppId: body.uniAppId || process.env.UNI_PUSH_APPID,
|
||||||
|
deployApi: body.deployApi || process.env.CLOUD_DEPLOY_API,
|
||||||
|
deployToken: body.deployToken || process.env.CLOUD_DEPLOY_TOKEN,
|
||||||
|
funcInvokeUrl: body.funcInvokeUrl || process.env.CLOUD_FUNC_URL,
|
||||||
|
pushToken: body.pushToken || process.env.PUSH_TOKEN,
|
||||||
|
testCid: body.testCid || process.env.TEST_DEVICE_CID,
|
||||||
|
deployPayload: body.deployPayload || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.uploadUrl) return res.status(400).json({ error: 'uploadUrl required (or set CLOUD_UPLOAD_URL env)' });
|
||||||
|
|
||||||
|
const result = await deployCloudFunction(options);
|
||||||
|
res.json({ ok: true, result });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('deploy endpoint error', e);
|
||||||
|
res.status(500).json({ ok: false, error: e && e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
60
server/scripts/start-delivery-backend.ps1
Normal file
60
server/scripts/start-delivery-backend.ps1
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
param(
|
||||||
|
[switch]$NoKill
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Ensure-Dir($p) {
|
||||||
|
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-ListeningPort($port) {
|
||||||
|
$lines = netstat -ano | Select-String (":$port ")
|
||||||
|
foreach ($l in $lines) {
|
||||||
|
$m = [regex]::Match($l.ToString(), "\sLISTENING\s+(\d+)$")
|
||||||
|
if ($m.Success) {
|
||||||
|
$procId = [int]$m.Groups[1].Value
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $procId -Force -ErrorAction Stop
|
||||||
|
Write-Host "Stopped PID $procId on port $port"
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to stop PID $procId on port $($port): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repo root is two levels up from this script: server\scripts\*.ps1
|
||||||
|
$root = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
||||||
|
$configPath = Resolve-Path (Join-Path $root "server\config.json")
|
||||||
|
|
||||||
|
if (-not $NoKill) {
|
||||||
|
Stop-ListeningPort 7201
|
||||||
|
Stop-ListeningPort 7301
|
||||||
|
}
|
||||||
|
|
||||||
|
Ensure-Dir (Join-Path $root "server\scripts")
|
||||||
|
|
||||||
|
$env:CONFIG_FILE = $configPath.Path
|
||||||
|
|
||||||
|
$services = @(
|
||||||
|
@{ Name = 'webhook-receiver'; Script = (Join-Path $root 'pages\mall\delivery\webhook-server\webhook-receiver.js'); Out = (Join-Path $root 'webhook-receiver.log'); Err = (Join-Path $root 'webhook-receiver.err.log') },
|
||||||
|
@{ Name = 'notify-worker'; Script = (Join-Path $root 'server\notify-worker.js'); Out = (Join-Path $root 'notify-worker.log'); Err = (Join-Path $root 'notify-worker.err.log') },
|
||||||
|
@{ Name = 'push-server'; Script = (Join-Path $root 'server\push-server.js'); Out = (Join-Path $root 'push-server.log'); Err = (Join-Path $root 'push-server.err.log') }
|
||||||
|
)
|
||||||
|
|
||||||
|
$pids = @{}
|
||||||
|
foreach ($s in $services) {
|
||||||
|
if (-not (Test-Path $s.Script)) { throw "Missing script: $($s.Script)" }
|
||||||
|
Write-Host "Starting $($s.Name)..."
|
||||||
|
$p = Start-Process -FilePath "node" -ArgumentList @($s.Script) -WorkingDirectory $root.Path -WindowStyle Hidden -RedirectStandardOutput $s.Out -RedirectStandardError $s.Err -PassThru
|
||||||
|
$pids[$s.Name] = $p.Id
|
||||||
|
Write-Host " PID=$($p.Id) out=$($s.Out) err=$($s.Err)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Ensure-Dir (Join-Path $root 'server\.runtime')
|
||||||
|
$pidsFile = Join-Path $root 'server\.runtime\delivery-backend.pids.json'
|
||||||
|
$json = $pids | ConvertTo-Json -Depth 5
|
||||||
|
[IO.File]::WriteAllText($pidsFile, $json + "`r`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
Write-Host "Saved pids: $pidsFile"
|
||||||
|
Write-Host "Done. Use test sender: node .\pages\mall\delivery\webhook-server\test-send.js"
|
||||||
60
server/scripts/stop-delivery-backend.ps1
Normal file
60
server/scripts/stop-delivery-backend.ps1
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Stop-ListeningPort($port) {
|
||||||
|
try {
|
||||||
|
$lines = netstat -ano | Select-String (":$port ")
|
||||||
|
foreach ($l in $lines) {
|
||||||
|
$m = [regex]::Match($l.ToString(), "\sLISTENING\s+(\d+)$")
|
||||||
|
if ($m.Success) {
|
||||||
|
$procId = [int]$m.Groups[1].Value
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $procId -Force -ErrorAction Stop
|
||||||
|
Write-Host "Stopped PID $procId on port $port"
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to stop PID $procId on port ${port}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to inspect port ${port}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
||||||
|
$pidsFile = Join-Path $root 'server\.runtime\delivery-backend.pids.json'
|
||||||
|
|
||||||
|
if (Test-Path $pidsFile) {
|
||||||
|
try {
|
||||||
|
# Windows PowerShell 5.1 + Set-Content -Encoding UTF8 often writes BOM; trim it before JSON parse.
|
||||||
|
$raw = Get-Content -Path $pidsFile -Raw -Encoding UTF8
|
||||||
|
if ($raw.Length -gt 0 -and $raw[0] -eq [char]0xFEFF) { $raw = $raw.Substring(1) }
|
||||||
|
$pids = $raw | ConvertFrom-Json
|
||||||
|
foreach ($prop in $pids.PSObject.Properties) {
|
||||||
|
$name = $prop.Name
|
||||||
|
$procPid = [int]$prop.Value
|
||||||
|
if ($procPid -gt 0) {
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $procPid -Force -ErrorAction Stop
|
||||||
|
Write-Host "Stopped $name PID=$procPid"
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to stop $name PID=${procPid}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Remove-Item -Path $pidsFile -Force -ErrorAction Stop
|
||||||
|
Write-Host "Removed pid file: $pidsFile"
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to remove pid file: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to parse ${pidsFile}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "No pid file found: $pidsFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: ensure ports are freed even if pid file is missing/invalid.
|
||||||
|
Stop-ListeningPort 7201
|
||||||
|
Stop-ListeningPort 7301
|
||||||
65
server/tools/ci-deploy.ps1
Normal file
65
server/tools/ci-deploy.ps1
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<#
|
||||||
|
Simple local CI helper (PowerShell):
|
||||||
|
- 打包 zip
|
||||||
|
- 可选上传到上传接口
|
||||||
|
- 可选触发部署 API
|
||||||
|
- 可选调用云函数做一次 smoke test
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# 只打包
|
||||||
|
.\ci-deploy.ps1 -Pack
|
||||||
|
|
||||||
|
# 打包并上传(提供 uploadUrl/uploadToken)
|
||||||
|
.\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token'
|
||||||
|
|
||||||
|
# 上传后触发部署 API
|
||||||
|
.\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token' -DeployApi 'https://your-deploy-api' -DeployToken 'token'
|
||||||
|
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$Pack,
|
||||||
|
[string]$UploadUrl,
|
||||||
|
[string]$UploadToken,
|
||||||
|
[string]$DeployApi,
|
||||||
|
[string]$DeployToken,
|
||||||
|
[string]$FuncInvokeUrl,
|
||||||
|
[string]$PushToken,
|
||||||
|
[string]$TestCid
|
||||||
|
)
|
||||||
|
|
||||||
|
Push-Location (Split-Path -Parent $MyInvocation.MyCommand.Definition)
|
||||||
|
if ($Pack) {
|
||||||
|
Write-Host "Packing cloud function..."
|
||||||
|
npm install archiver node-fetch form-data | Out-Null
|
||||||
|
node .\deploy-cloudfunc.js
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($UploadUrl) {
|
||||||
|
Write-Host "Uploading to $UploadUrl"
|
||||||
|
$zip = Join-Path ..\dist testUnipush2.zip
|
||||||
|
if (!(Test-Path $zip)) { Write-Error "zip not found: $zip"; exit 2 }
|
||||||
|
$headers = @{}
|
||||||
|
if ($UploadToken) { $headers.Add('Authorization', "Bearer $UploadToken") }
|
||||||
|
$form = @{ file = Get-Item $zip }
|
||||||
|
# Use curl.exe for simple multipart upload
|
||||||
|
$curlArgs = @('-sS','-X','POST',$UploadUrl)
|
||||||
|
if ($UploadToken) { $curlArgs += @('-H',"Authorization: Bearer $UploadToken") }
|
||||||
|
$curlArgs += @('-F',"file=@$zip")
|
||||||
|
& curl.exe @curlArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($DeployApi) {
|
||||||
|
Write-Host "Triggering deploy API: $DeployApi"
|
||||||
|
$body = @{ uploadUrl = $UploadUrl } | ConvertTo-Json
|
||||||
|
$headers = @()
|
||||||
|
if ($DeployToken) { $headers += @('-H',"Authorization: Bearer $DeployToken") }
|
||||||
|
& curl.exe -sS -X POST $DeployApi -H 'Content-Type: application/json' $headers -d $body
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($FuncInvokeUrl) {
|
||||||
|
Write-Host "Invoking function: $FuncInvokeUrl"
|
||||||
|
$payload = @{ token = $PushToken; push_clientid = $TestCid; title='CI Test'; content='hello from CI'; payload = @{} } | ConvertTo-Json
|
||||||
|
& curl.exe -sS -X POST $FuncInvokeUrl -H 'Content-Type: application/json' -d $payload
|
||||||
|
}
|
||||||
|
|
||||||
|
Pop-Location
|
||||||
81
server/tools/deploy-cloudfunc-service.js
Normal file
81
server/tools/deploy-cloudfunc-service.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
|
||||||
|
function packDirToZip(srcDir, outPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const output = fs.createWriteStream(outPath);
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
output.on('close', () => resolve({ bytes: archive.pointer(), path: outPath }));
|
||||||
|
archive.on('error', err => reject(err));
|
||||||
|
archive.pipe(output);
|
||||||
|
archive.directory(srcDir, false);
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadZip(url, token, zipPath, extraFields = {}) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', fs.createReadStream(zipPath));
|
||||||
|
for (const k of Object.keys(extraFields)) form.append(k, extraFields[k]);
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
const resp = await fetch(url, { method: 'POST', headers: Object.assign(headers, form.getHeaders()), body: form });
|
||||||
|
const text = await resp.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch (e) { json = { statusText: text }; }
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerDeployApi(deployApi, deployToken, payload = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (deployToken) headers.Authorization = `Bearer ${deployToken}`;
|
||||||
|
const resp = await fetch(deployApi, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||||||
|
const json = await resp.json().catch(() => null);
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeFunction(funcUrl, pushToken, testCid, title = 'CI Test', content = 'hello from backend') {
|
||||||
|
const body = { token: pushToken, push_clientid: testCid, title, content, payload: {} };
|
||||||
|
const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const json = await resp.json().catch(() => null);
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deployCloudFunction(options = {}) {
|
||||||
|
const funcDir = options.funcDir || path.join(__dirname, '..', '..', 'uniCloud-alipay', 'cloudfunctions', 'testUnipush2');
|
||||||
|
const outDir = path.join(__dirname, '..', 'dist');
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const zipPath = path.join(outDir, path.basename(funcDir) + '.zip');
|
||||||
|
|
||||||
|
const result = { packed: null, uploaded: null, deployed: null, invoked: null };
|
||||||
|
|
||||||
|
// pack
|
||||||
|
const packRes = await packDirToZip(funcDir, zipPath);
|
||||||
|
result.packed = packRes;
|
||||||
|
|
||||||
|
// upload
|
||||||
|
if (!options.uploadUrl) return result;
|
||||||
|
const extra = {};
|
||||||
|
if (options.uniAppId) extra.appId = options.uniAppId;
|
||||||
|
const up = await uploadZip(options.uploadUrl, options.uploadToken, zipPath, extra);
|
||||||
|
result.uploaded = up;
|
||||||
|
|
||||||
|
// trigger deploy API (optional)
|
||||||
|
if (options.deployApi) {
|
||||||
|
const payload = Object.assign({}, options.deployPayload || {}, { uploadUrl: options.uploadUrl, uploadResponse: up.body });
|
||||||
|
const dp = await triggerDeployApi(options.deployApi, options.deployToken, payload);
|
||||||
|
result.deployed = dp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// invoke function for smoke test (optional)
|
||||||
|
if (options.funcInvokeUrl && options.pushToken && options.testCid) {
|
||||||
|
const iv = await invokeFunction(options.funcInvokeUrl, options.pushToken, options.testCid, options.testTitle, options.testContent);
|
||||||
|
result.invoked = iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { deployCloudFunction, packDirToZip, uploadZip, triggerDeployApi, invokeFunction };
|
||||||
233
server/tools/deploy-cloudfunc.js
Normal file
233
server/tools/deploy-cloudfunc.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// 打包并可选上传云函数到指定上传接口的工具脚本(通用模版)
|
||||||
|
// 依赖: archiver, node-fetch, form-data
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function ensureDeps() {
|
||||||
|
try {
|
||||||
|
require.resolve('archiver');
|
||||||
|
require.resolve('node-fetch');
|
||||||
|
require.resolve('form-data');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('\n依赖缺失:请先在仓库根或 server 目录安装依赖:');
|
||||||
|
console.error(' cd server');
|
||||||
|
console.error(' npm install archiver node-fetch form-data');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
function packDirToZip(srcDir, outPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const output = fs.createWriteStream(outPath);
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
output.on('close', () => resolve({ bytes: archive.pointer(), path: outPath }));
|
||||||
|
archive.on('error', err => reject(err));
|
||||||
|
archive.pipe(output);
|
||||||
|
archive.directory(srcDir, false);
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadZip(url, token, zipPath, extraFields = {}) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', fs.createReadStream(zipPath));
|
||||||
|
for (const k of Object.keys(extraFields)) form.append(k, extraFields[k]);
|
||||||
|
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
const resp = await fetch(url, { method: 'POST', headers: Object.assign(headers, form.getHeaders()), body: form });
|
||||||
|
const text = await resp.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch (e) { json = { statusText: text }; }
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runUnicloudUpload(spaceId, funcDir, funcName) {
|
||||||
|
try {
|
||||||
|
// 尝试使用全局 unicloud
|
||||||
|
let args = ['upload', '-p', spaceId, '-f', funcName, funcDir];
|
||||||
|
console.log('尝试使用全局 `unicloud` CLI:', ['unicloud', ...args].join(' '));
|
||||||
|
let r = spawnSync('unicloud', args, { stdio: 'inherit' });
|
||||||
|
if (r && r.status === 0) return { ok: true };
|
||||||
|
|
||||||
|
// 否则尝试使用 npx 调用包内命令(不要求全局安装)
|
||||||
|
args = ['@dcloudio/unicloud-cli', 'upload', '-p', spaceId, '-f', funcName, funcDir];
|
||||||
|
console.log('尝试使用 npx 调用 unicloud-cli:', ['npx', ...args].join(' '));
|
||||||
|
r = spawnSync('npx', args, { stdio: 'inherit' });
|
||||||
|
if (r && r.status === 0) return { ok: true };
|
||||||
|
|
||||||
|
return { ok: false, error: 'unicloud CLI 上传失败,返回码: ' + (r && r.status) };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callDeployApi(deployApi, deployToken, uploadResult) {
|
||||||
|
try {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (deployToken) headers.Authorization = `Bearer ${deployToken}`;
|
||||||
|
const body = Object.assign({ uploadResult }, {});
|
||||||
|
const resp = await fetch(deployApi, { method: 'POST', headers, body: JSON.stringify(body) });
|
||||||
|
const txt = await resp.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(txt); } catch (e) { json = { statusText: txt }; }
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeCloudFunction(funcUrl, pushToken, testCid) {
|
||||||
|
try {
|
||||||
|
const body = { token: pushToken || process.env.PUSH_TOKEN || null };
|
||||||
|
if (testCid) body.push_clientid = testCid;
|
||||||
|
body.title = 'smoke-test';
|
||||||
|
body.content = 'smoke-test';
|
||||||
|
const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const txt = await resp.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(txt); } catch (e) { json = { statusText: txt }; }
|
||||||
|
return { ok: resp.ok, status: resp.status, body: json };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDeps();
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const args = {};
|
||||||
|
// 明确哪些参数需要值;若缺失则报错并退出,避免像 --upload 没给 URL 的情况
|
||||||
|
const expectsValue = new Set(['upload','deployApi','deployToken','invokeUrl','funcInvokeUrl','pushToken','testCid','dir','spaceId','name']);
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (!a.startsWith('--')) continue;
|
||||||
|
const key = a.replace('--','');
|
||||||
|
const next = argv[i+1] && !argv[i+1].startsWith('--') ? argv[i+1] : undefined;
|
||||||
|
if (expectsValue.has(key)) {
|
||||||
|
if (!next) {
|
||||||
|
console.error(`参数错误:--${key} 需要一个值(例如 --${key} <value>)。`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
args[key] = next;
|
||||||
|
i++; // skip value
|
||||||
|
} else {
|
||||||
|
args[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcDir = args.dir || process.env.CLOUD_FUNCTION_DIR || path.join(__dirname, '..', '..', 'uniCloud-alipay', 'cloudfunctions', 'testUnipush2');
|
||||||
|
const outDir = path.join(__dirname, '..', 'dist');
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const zipPath = path.join(outDir, path.basename(funcDir) + '.zip');
|
||||||
|
|
||||||
|
console.log('打包目录:', funcDir);
|
||||||
|
try {
|
||||||
|
const r = await packDirToZip(funcDir, zipPath);
|
||||||
|
console.log('打包完成 ->', zipPath, `(${r.bytes} bytes)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('打包失败', e);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadUrl = args.upload || process.env.CLOUD_UPLOAD_URL;
|
||||||
|
const uploadToken = process.env.CLOUD_UPLOAD_TOKEN || process.env.CLOUD_TOKEN;
|
||||||
|
// 如果没有提供 upload URL,但提供了 UniCloud 的 spaceId,则默认使用官方上传 API
|
||||||
|
const spaceId = args.spaceId || process.env.CLOUD_UNICLOUD_SPACEID || process.env.SPACE_ID || null;
|
||||||
|
if (!uploadUrl && spaceId) {
|
||||||
|
uploadUrl = process.env.CLOUD_UPLOAD_URL || 'https://unicloud.dcloud.net.cn/api/uni-cloud/function/upload';
|
||||||
|
console.log('未指定 --upload,检测到 spaceId,使用 UniCloud 上传 API:', uploadUrl);
|
||||||
|
}
|
||||||
|
if (!uploadUrl) {
|
||||||
|
console.log('\n未配置上传地址 (CLOUD_UPLOAD_URL),只生成 zip 文件。');
|
||||||
|
console.log('若要上传,请设置环境变量 CLOUD_UPLOAD_URL 与 CLOUD_UPLOAD_TOKEN,或使用 --upload <URL> 参数,或提供 --spaceId 使用 unicloud 上传 API。');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('上传到:', uploadUrl);
|
||||||
|
try {
|
||||||
|
const extra = {};
|
||||||
|
if (process.env.UNI_PUSH_APPID) extra.appId = process.env.UNI_PUSH_APPID;
|
||||||
|
// 若使用 UniCloud 官方上传接口,需要传入 spaceId 与 name
|
||||||
|
if (spaceId) extra.spaceId = spaceId;
|
||||||
|
const funcName = args.name || path.basename(funcDir);
|
||||||
|
if (funcName) extra.name = funcName;
|
||||||
|
// 本地文件校验
|
||||||
|
if (!fs.existsSync(zipPath)) {
|
||||||
|
console.error('打包文件不存在:', zipPath);
|
||||||
|
process.exit(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await uploadZip(uploadUrl, uploadToken, zipPath, extra);
|
||||||
|
console.log('上传响应:', res.status, JSON.stringify(res.body));
|
||||||
|
|
||||||
|
// 若 HTTP 上传被拒绝(例如 405)或不被接受,且提供了 spaceId,则尝试使用 unicloud CLI 上传回退
|
||||||
|
if ((!res.ok || res.status === 405) && spaceId) {
|
||||||
|
console.log('HTTP 上传失败或被拒绝,尝试使用 unicloud CLI 上传回退...');
|
||||||
|
const funcName = args.name || path.basename(funcDir);
|
||||||
|
const cli = runUnicloudUpload(spaceId, funcDir, funcName);
|
||||||
|
if (cli.ok) {
|
||||||
|
console.log('unicloud CLI 上传成功(回退)。');
|
||||||
|
// 将 published 设为 true,后续进行 smoke-test
|
||||||
|
res = { ok: true, status: 0, body: { source: 'unicloud-cli' } };
|
||||||
|
} else {
|
||||||
|
console.error('unicloud CLI 上传也失败:', cli.error);
|
||||||
|
process.exit(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) process.exit(3);
|
||||||
|
|
||||||
|
// 常见错误检测:如果把包 POST 到了函数的 invoke URL,云函数通常直接返回业务层错误
|
||||||
|
// e.g. {"errCode":400,"errMsg":"push_clientid required"}
|
||||||
|
try {
|
||||||
|
const probe = JSON.stringify(res.body || {});
|
||||||
|
if (/push_clientid|required/i.test(probe) && /errCode|errMsg|error/i.test(probe)) {
|
||||||
|
console.error('\n检测到上传目标看起来像云函数的调用(invoke)URL,而不是上传/发布 API。');
|
||||||
|
console.error('请使用提供商的上传/发布接口(不是函数的 /test 或类似 invoke 路径)。');
|
||||||
|
console.error('建议:在 HBuilderX 或 控制台 的 Network 面板中捕获 “上传/发布” 请求,或联系平台文档获取上传 API。');
|
||||||
|
console.error('当前响应(供参考):', JSON.stringify(res.body));
|
||||||
|
process.exit(6);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore probe errors */ }
|
||||||
|
|
||||||
|
// 简单判断上传响应是否已经标记为已发布/上线(根据不同厂商响应结构调整)
|
||||||
|
let published = false;
|
||||||
|
try {
|
||||||
|
const b = res.body || {};
|
||||||
|
if (b.errCode === 0 || b.errMsg === 'success' || /success|online|published/i.test(JSON.stringify(b))) published = true;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
// 如果未明确发布且提供了 deploy API,则调用之以触发发布
|
||||||
|
const deployApi = args.deployApi || process.env.CLOUD_DEPLOY_API || null;
|
||||||
|
const deployToken = args.deployToken || process.env.CLOUD_DEPLOY_TOKEN || null;
|
||||||
|
let deployResult = null;
|
||||||
|
if (!published && deployApi) {
|
||||||
|
console.log('调用部署 API:', deployApi);
|
||||||
|
deployResult = await callDeployApi(deployApi, deployToken, res.body);
|
||||||
|
console.log('部署 API 响应:', deployResult && (deployResult.body || deployResult.error));
|
||||||
|
if (deployResult && deployResult.ok) published = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选:若指定了 funcInvokeUrl 或 CLOUD_FUNC_URL,则在上传/发布后做一次 smoke-test 调用
|
||||||
|
const funcInvokeUrl = args.invokeUrl || args.funcInvokeUrl || process.env.CLOUD_FUNC_URL || null;
|
||||||
|
const pushToken = args.pushToken || process.env.PUSH_TOKEN || null;
|
||||||
|
const testCid = args.testCid || process.env.TEST_DEVICE_CID || null;
|
||||||
|
if (funcInvokeUrl) {
|
||||||
|
console.log('调用云函数做 smoke-test:', funcInvokeUrl);
|
||||||
|
const inv = await invokeCloudFunction(funcInvokeUrl, pushToken, testCid);
|
||||||
|
console.log('smoke-test 响应:', inv && (inv.body || inv.error));
|
||||||
|
if (!inv.ok) process.exit(5);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('上传失败', e);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
52
server/try_supa_keys.js
Normal file
52
server/try_supa_keys.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const fetch = require('node-fetch')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
const SUPA = 'http://192.168.1.62:18000'
|
||||||
|
const USER = 'a8e3a568-fc1f-4237-bcc5-5722e2fca0a3'
|
||||||
|
|
||||||
|
function findJwtStrings(dir) {
|
||||||
|
const out = new Set()
|
||||||
|
const files = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const full = path.join(dir, f.name)
|
||||||
|
if (f.isDirectory()) {
|
||||||
|
if (f.name === 'node_modules' || f.name === '.git') continue
|
||||||
|
for (const s of findJwtStrings(full)) out.add(s)
|
||||||
|
} else {
|
||||||
|
const txt = fs.readFileSync(full, 'utf8')
|
||||||
|
const re = /eyJ[0-9A-Za-z_-]{30,}\.[0-9A-Za-z_-]{30,}\.[0-9A-Za-z_-]{30,}/g
|
||||||
|
const m = txt.match(re)
|
||||||
|
if (m) m.forEach(x => out.add(x))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const keys = Array.from(findJwtStrings(ROOT))
|
||||||
|
console.log('Found', keys.length, 'candidate keys')
|
||||||
|
for (const k of keys) {
|
||||||
|
try {
|
||||||
|
const url = `${SUPA.replace(/\/$/, '')}/rest/v1/push_devices?user_id=eq.${encodeURIComponent(USER)}`
|
||||||
|
process.stdout.write('\nTrying key prefix: ' + k.substring(0,20) + '...')
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { apikey: k, Authorization: `Bearer ${k}`, Accept: 'application/json' },
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
const status = resp.status
|
||||||
|
const body = await resp.text()
|
||||||
|
console.log(' status=', status)
|
||||||
|
console.log('body preview:', body && body.length > 1000 ? body.substring(0,1000) + '...' : body)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' error', e && e.message ? e.message : e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.length === 0) console.log('No JWT-like keys found in repo')
|
||||||
|
})()
|
||||||
67
server/verify_supa_token.js
Normal file
67
server/verify_supa_token.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function b64urlDecode(s) {
|
||||||
|
s = String(s || '')
|
||||||
|
s = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
while (s.length % 4) s += '='
|
||||||
|
return Buffer.from(s, 'base64').toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlEncode(buf) {
|
||||||
|
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyHS256(token, secret) {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return { ok: false, reason: 'bad token format' }
|
||||||
|
const [h64, p64, s64] = parts
|
||||||
|
const signingInput = `${h64}.${p64}`
|
||||||
|
const expected = b64urlEncode(crypto.createHmac('sha256', secret).update(signingInput).digest())
|
||||||
|
return { ok: expected === s64, expected, actual: s64 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log('Usage: node server/verify_supa_token.js <token> [secret]')
|
||||||
|
console.log('Or set env vars: TOKEN and SECRET and run without args')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = process.argv[2] || process.env.TOKEN
|
||||||
|
const secret = process.argv[3] || process.env.SECRET || process.env.JWT_SECRET || process.env.SERVICE_ROLE_KEY || process.env.SUPA_KEY
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
usage()
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
console.error('No secret provided. Pass as second arg or set SECRET/JWT_SECRET/SERVICE_ROLE_KEY env var')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
const header = JSON.parse(b64urlDecode(parts[0]))
|
||||||
|
const payload = JSON.parse(b64urlDecode(parts[1]))
|
||||||
|
|
||||||
|
console.log('Header:')
|
||||||
|
console.log(JSON.stringify(header, null, 2))
|
||||||
|
console.log('Payload:')
|
||||||
|
console.log(JSON.stringify(payload, null, 2))
|
||||||
|
|
||||||
|
if ((header.alg || '').toUpperCase() !== 'HS256') {
|
||||||
|
console.warn('Warning: token alg is not HS256; this verifier only checks HS256 signatures')
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = verifyHS256(token, secret)
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('\nSignature: VALID (HMAC-SHA256)')
|
||||||
|
} else {
|
||||||
|
console.log('\nSignature: INVALID')
|
||||||
|
console.log('expected:', res.expected)
|
||||||
|
console.log('actual :', res.actual)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('verify error', e && e.message ? e.message : e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
415
server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md
Normal file
415
server/消息推送文档/DELIVERY_E2E_TROUBLESHOOTING_20260310.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# 物流 Webhook → 通知入库 → 推送到手机:联调问题记录(2026-03-10)
|
||||||
|
|
||||||
|
本文记录本次将链路跑通时遇到的阻塞点、根因与修复方式,便于后续环境复现与排障。
|
||||||
|
|
||||||
|
目标链路:
|
||||||
|
1) webhook-receiver 接收物流回调并落库
|
||||||
|
2) DB/服务侧写入 `notify_queue`
|
||||||
|
3) `server/notify-worker.js` 消费 `notify_queue`,生成 `express_notifications`
|
||||||
|
4) `server/push-server.js` 的 consumer 轮询 `express_notifications`,调用 `CLOUD_FUNC_URL` 推送到设备 `cid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 症状:notify-worker 报 `order not found for waybill`
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- `notify_queue.process_status=skipped` 或 `failed`
|
||||||
|
- `last_error=order not found for waybill`
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- `public.ml_orders` 启用了 RLS。
|
||||||
|
- 后端通过 PostgREST 仅使用 `apikey`(或无法解码的 Bearer)访问时,`auth.uid()` 为空,RLS policy 不成立,导致对 `ml_orders` 的查询返回 `200 []`(看起来像“无数据”,实为被行级安全过滤)。
|
||||||
|
|
||||||
|
**验证方法**
|
||||||
|
- 对比:SQL Editor 里能查到订单,但经 `/rest/v1/ml_orders` 查不到。
|
||||||
|
|
||||||
|
**临时修复(用于快速跑通链路)**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE public.ml_orders DISABLE ROW LEVEL SECURITY;
|
||||||
|
```
|
||||||
|
|
||||||
|
> 备注:你当前观察到“把安全策略关上就可以进行推送”,就是因为 notify-worker 能重新读到 `ml_orders.user_id/merchant_id` 以解析收件人。
|
||||||
|
|
||||||
|
**恢复建议(生产化方向)**
|
||||||
|
- 不建议长期关闭 RLS。
|
||||||
|
- 推荐做法(本仓库已支持):
|
||||||
|
- 保持全局 `SUPA_USE_BEARER=false`(避免影响其它服务/自托管网关 JWT 配置不一致带来的 `PGRST301`)
|
||||||
|
- 仅对 notify-worker 开启 Bearer:在 `server/config.json` 设置 `NOTIFY_WORKER_SUPA_USE_BEARER=true`
|
||||||
|
- 确保 `SUPA_KEY`/`SERVICE_ROLE_KEY` 是可用的 service_role key(JWT)
|
||||||
|
- 备选(慎用/止血):通过 RPC(`SECURITY DEFINER`)完成必要查询;避免依赖 `DISABLE RLS`。
|
||||||
|
- 执行 SQL:`pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql`
|
||||||
|
- 在数据库中设置 token(用于 RPC 显式鉴权):
|
||||||
|
- 示例:`ALTER ROLE authenticator SET app.notify_worker_token = '<YOUR_LONG_RANDOM_TOKEN>';`(按你的 PostgREST 连接角色调整)
|
||||||
|
- notify-worker 配置:
|
||||||
|
- `NOTIFY_WORKER_USE_RLS_SAFE_RPC=true`
|
||||||
|
- `NOTIFY_WORKER_RPC_TOKEN=<YOUR_LONG_RANDOM_TOKEN>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 症状:写入 `express_notifications` 时报 `42P10`
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- `notify_queue.process_status=failed`
|
||||||
|
- `last_error` 包含:
|
||||||
|
- `there is no unique or exclusion constraint matching the ON CONFLICT specification`
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- notify-worker / push-server 使用 PostgREST upsert:
|
||||||
|
- `POST /rest/v1/express_notifications?on_conflict=message_id`
|
||||||
|
- 但数据库侧 `express_notifications(message_id)` 只有“部分唯一索引”(例如 `WHERE message_id IS NOT NULL`),无法匹配 `ON CONFLICT(message_id)`,触发 `42P10`。
|
||||||
|
|
||||||
|
**修复(改为普通唯一索引)**
|
||||||
|
- 执行脚本:
|
||||||
|
- `pages/mall/delivery/doc/需求文档/20260310_fix_express_notifications_on_conflict_message_id.sql`
|
||||||
|
|
||||||
|
核心 SQL(摘要):
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS public.ux_express_notifications_message_id;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_express_notifications_message_id
|
||||||
|
ON public.express_notifications(message_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复后验证**
|
||||||
|
- `notify_queue` 最新记录不再失败;`express_notifications` 能正常生成/更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 症状:push-server 启动失败 `EADDRINUSE 0.0.0.0:7301`
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- `node server/push-server.js` 直接退出
|
||||||
|
- 提示端口占用
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- 7301 端口已有 node 进程占用(重复启动或遗留进程)
|
||||||
|
|
||||||
|
**排查/处理(Windows)**
|
||||||
|
```powershell
|
||||||
|
netstat -ano | Select-String ":7301 "
|
||||||
|
Get-Process -Id <PID>
|
||||||
|
Stop-Process -Id <PID> -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 症状:PGRST301(JWT 无法解码)导致访问异常
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- PostgREST 返回 `PGRST301` 或“JWT cannot be decoded/verified”
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- 在自托管/网关配置不一致时,手动发送 `Authorization: Bearer <SUPA_KEY>` 可能触发 JWT 解码失败。
|
||||||
|
- 本仓库对 Supabase REST 调用默认只发 `apikey`,仅在显式 `SUPA_USE_BEARER=true` 时才附加 Bearer。
|
||||||
|
|
||||||
|
**处理建议**
|
||||||
|
- 保持 `SUPA_USE_BEARER=false`(默认)
|
||||||
|
- 使用可用的 `apikey/service_role key` 走 Kong key-auth
|
||||||
|
|
||||||
|
**如果你的目标是“用 Bearer(service_role JWT) 绕过 RLS”**(而不是仅仅避免 401):
|
||||||
|
- 需要修复自托管配置一致性:确保 PostgREST(rest) 实际加载的 `PGRST_JWT_SECRET` 与签发 `SERVICE_ROLE_KEY` 的 `JWT_SECRET` 完全一致(算法/格式也要匹配),并重启 rest 组件。
|
||||||
|
- 在 JWT 未修复前,如需临时止血可参考 RPC 备选方案(慎用):执行 `pages/mall/delivery/doc/需求文档/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql`(仅建议用于短期联调/排障)。
|
||||||
|
|
||||||
|
### 4.1 具体怎么修(推荐按这个顺序做)
|
||||||
|
|
||||||
|
> 目标:让 `/rest/v1/*` 能接受 `Authorization: Bearer <SERVICE_ROLE_KEY>`,不再返回 401 `PGRST301`。
|
||||||
|
|
||||||
|
补充说明(结合你当前的 docker-compose):
|
||||||
|
- 你贴的 compose 里 `rest` 使用 `PGRST_JWT_SECRET: ${JWT_SECRET}`,所以**如果容器已按该 env 重启生效**,通常就不是“rest/auth secret 不一致”。
|
||||||
|
- 此时最常见根因变成:**你正在使用的 `SERVICE_ROLE_KEY` 并不是用当前这份 `JWT_SECRET` 签出来的**(即 key/secret 不匹配),或者只是**改了 `.env` 但容器没重启**仍在用旧 env。
|
||||||
|
|
||||||
|
1) **确认 rest(PostgREST) 当前加载的密钥**(不要直接到处打印明文;建议只比对 hash):
|
||||||
|
|
||||||
|
在 Supabase 机器上(Docker 场景示例,容器名因部署不同会不同):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) 找到 PostgREST/rest 容器
|
||||||
|
docker ps --format "{{.Names}}" | Select-String -Pattern "rest|postgrest"
|
||||||
|
|
||||||
|
# 2) 取 PGRST_JWT_SECRET 的 sha256(避免直接输出明文)
|
||||||
|
$restName = (docker ps --format "{{.Names}}" | Select-String -Pattern "rest|postgrest" | Select-Object -First 1).Line.Trim()
|
||||||
|
docker exec $restName sh -lc 'printf %s "$PGRST_JWT_SECRET" | sha256sum'
|
||||||
|
|
||||||
|
# 3) 顺手检查几个容易踩坑的配置项
|
||||||
|
docker exec $restName sh -lc 'echo PGRST_JWT_SECRET_IS_BASE64=$PGRST_JWT_SECRET_IS_BASE64'
|
||||||
|
docker exec $restName sh -lc 'echo PGRST_JWT_AUD=$PGRST_JWT_AUD'
|
||||||
|
```
|
||||||
|
|
||||||
|
2) **确认 auth(GoTrue) 使用的 JWT_SECRET**(同样用 hash 比对):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker ps --format "{{.Names}}" | Select-String -Pattern "auth|gotrue"
|
||||||
|
$authName = (docker ps --format "{{.Names}}" | Select-String -Pattern "auth|gotrue" | Select-Object -First 1).Line.Trim()
|
||||||
|
docker exec $authName sh -lc 'printf %s "$JWT_SECRET" | sha256sum'
|
||||||
|
```
|
||||||
|
|
||||||
|
如果这两个 hash **不一致**,就会出现“auth 签发的 JWT / service_role key,rest 解不开”的典型问题。
|
||||||
|
|
||||||
|
如果这两个 hash **一致但仍然 PGRST301**,优先做下面两件事:
|
||||||
|
|
||||||
|
2.1) **验证你手上的 SERVICE_ROLE_KEY 是否匹配 JWT_SECRET(最关键)**
|
||||||
|
|
||||||
|
在你的开发机执行(只在本机跑,不要把 secret 提交到仓库):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 预期输出:Signature VALID
|
||||||
|
node .\server\verify_supa_token.js <SERVICE_ROLE_KEY_JWT> <JWT_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果是 `Signature INVALID`:说明当前 `SERVICE_ROLE_KEY` 不是用这份 `JWT_SECRET` 签的。
|
||||||
|
- 处理方式二选一:
|
||||||
|
- 把 `JWT_SECRET` 改回“当初生成 key 的那份”并重启(影响面大,不推荐在不确定时直接动生产)
|
||||||
|
- 重新生成一套新的 `ANON_KEY/SERVICE_ROLE_KEY`(与当前 `JWT_SECRET` 匹配),并同步更新:Kong(key-auth)+ 你的后端配置(例如 `server/config.json`)。
|
||||||
|
|
||||||
|
可以使用本仓库脚本从 `JWT_SECRET` 生成匹配的 key(HS256):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:JWT_SECRET='<your jwt secret>'
|
||||||
|
$env:SUPA_JWT_ISSUER='supabase-1' # 与你现有 key 的 iss 保持一致
|
||||||
|
# $env:SUPA_JWT_AUD='authenticated' # 如需带 aud 再打开
|
||||||
|
node .\server\tools\generate-supabase-keys.js
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你不想在命令行里直接暴露 `JWT_SECRET`,可以用交互式 PowerShell 脚本(输入隐藏):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\server\tools\generate-supabase-keys.ps1 -Issuer 'supabase-1' -Years 5
|
||||||
|
```
|
||||||
|
|
||||||
|
生成后要做的同步(否则 Kong key-auth 仍然认旧 key):
|
||||||
|
- 更新 Supabase 的 `.env`:把 `ANON_KEY=` 与 `SERVICE_ROLE_KEY=` 替换为新生成的值
|
||||||
|
- 重启 kong(推荐同时重启 rest/auth 以减少缓存/配置疑难):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose restart kong rest auth
|
||||||
|
```
|
||||||
|
|
||||||
|
然后更新本仓库服务端配置(至少 `server/config.json` 的 `SUPA_KEY`)为新的 `SERVICE_ROLE_KEY`,再用下面的方式验证。
|
||||||
|
|
||||||
|
2.2) **确认容器确实吃到了最新 env(改完一定要 restart)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在 Supabase 机器上(docker compose)
|
||||||
|
docker compose restart rest auth kong
|
||||||
|
```
|
||||||
|
|
||||||
|
3) **确保 ANON_KEY / SERVICE_ROLE_KEY 是用同一套 JWT_SECRET 生成的**
|
||||||
|
|
||||||
|
- 你不能从一个 JWT token 反推出 secret,所以通常的做法是:
|
||||||
|
- 选定一套 `JWT_SECRET`(rest/auth 全部用它)
|
||||||
|
- 重新生成 `ANON_KEY` 与 `SERVICE_ROLE_KEY`
|
||||||
|
- 同步更新 Kong/key-auth 里的 key(否则 `apikey` 会失效)
|
||||||
|
|
||||||
|
你可以用本仓库脚本验证“某个 service_role token 是否匹配某个 secret”(HS256):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在你的开发机执行(不要把 secret 提交到仓库)
|
||||||
|
node .\server\verify_supa_token.js <SERVICE_ROLE_KEY_JWT> <JWT_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
4) **修改完配置后一定要重启**
|
||||||
|
|
||||||
|
- 至少重启:`rest(PostgREST)` 与 `auth(GoTrue)`
|
||||||
|
- 如果你改了 `ANON_KEY/SERVICE_ROLE_KEY`,通常还需要重启 `kong`(让 key-auth 使用新 key)
|
||||||
|
|
||||||
|
5) **验证 Bearer 是否修复**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$cfg = Get-Content .\server\config.json -Raw | ConvertFrom-Json
|
||||||
|
$u = $cfg.SUPA_URL.TrimEnd('/')
|
||||||
|
$token = $cfg.SUPA_KEY # 确保这里是 service_role JWT
|
||||||
|
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$u/rest/v1/ml_orders?select=id&limit=1" -Headers @{
|
||||||
|
apikey = $token
|
||||||
|
Authorization = "Bearer $token"
|
||||||
|
Accept = 'application/json'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以用本仓库脚本快速测一次(更直观):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CONFIG_FILE=(Resolve-Path .\server\config.json)
|
||||||
|
node .\server\tools\test-postgrest-bearer.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- 修好后的期望:不再是 401 `PGRST301`。
|
||||||
|
|
||||||
|
相关实现:
|
||||||
|
- `server/push-server.js` 的 `supaFetch` 逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 症状:`503 PGRST002`(PostgREST schema cache / 数据库不可用)
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- webhook-receiver 返回 `502 { ok:false, message:'... HTTP 503 {"code":"PGRST002" ... }' }`
|
||||||
|
- 或 worker 日志持续刷:
|
||||||
|
- `HTTP 503 {"code":"PGRST002","message":"Could not query the database for the schema cache. Retrying."}`
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- 这不是 RLS 问题。
|
||||||
|
- 这是 **PostgREST 无法连接到 Postgres**(或数据库正在重启/不可用/连接池耗尽/磁盘满等),因此无法构建/刷新 schema cache。
|
||||||
|
|
||||||
|
**快速验证**
|
||||||
|
```powershell
|
||||||
|
# 直连 REST 读一条数据(只要能返回 200/空数组就说明 REST 已恢复)
|
||||||
|
$cfg=Get-Content .\server\config.json -Raw | ConvertFrom-Json
|
||||||
|
$u=($cfg.SUPA_URL.TrimEnd('/')) + '/rest/v1/platform_express_waybills?select=id&limit=1'
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri $u -Headers @{ apikey=$cfg.SUPA_KEY; Accept='application/json' } -Method GET
|
||||||
|
```
|
||||||
|
|
||||||
|
**处理建议(按优先级)**
|
||||||
|
1) 在 Supabase 部署机器上确认 Postgres 是否正常(能否连接、磁盘是否满、CPU/内存是否打满)。
|
||||||
|
2) 重启 PostgREST/rest 服务(多数情况下会自动恢复 schema cache)。
|
||||||
|
3) 如使用 docker-compose:优先重启 db 与 rest(容器名因环境不同可能不同)。
|
||||||
|
4) 待 REST 恢复后再重跑本仓库的 3 个 Node 后台(或等待它们自动恢复)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 症状:数据库显示 `send_status=success`,但手机没弹通知
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- `express_notifications.send_status=success`
|
||||||
|
- 但手机端没有系统通知/横幅
|
||||||
|
|
||||||
|
**常见原因**
|
||||||
|
1) 推送内容为空(部分通道/客户端不会展示空内容通知)
|
||||||
|
2) 客户端处于前台/透传模式:消息到达但不自动弹系统通知(需要客户端本地展示)
|
||||||
|
3) 手机系统通知权限/渠道被关闭、省电策略限制
|
||||||
|
|
||||||
|
**本次定位与修复**
|
||||||
|
- notify-worker 生成的 `express_notifications` 通常只有 `event_text_safe`(可作为标题),body/content 可能为空。
|
||||||
|
- push-server consumer 调云函数时原先传入的 `content` 可能为空,导致“云函数返回成功,但不展示”。
|
||||||
|
- 已在 `server/push-server.js` 增加兜底:当 body/content 为空时,使用 title/event_text_safe 作为 content。
|
||||||
|
|
||||||
|
验证方法(直接调用云函数对 CID 推送)
|
||||||
|
- 观察云函数返回中是否包含类似 `successed_online`;并对比手机是否实际展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 症状:merchant 侧 `send_status=no-targets`
|
||||||
|
|
||||||
|
**现象**
|
||||||
|
- `express_notifications.aud=merchant` 的记录 `send_status=no-targets`
|
||||||
|
- `last_error=no active devices`
|
||||||
|
|
||||||
|
**根因**
|
||||||
|
- `push_devices` 表结构本身包含 `merchant_id` 字段,但实际设备注册时常见只写入 `user_id`,导致:
|
||||||
|
- notify-worker 能生成 `aud=merchant, recipient_id=<merchant_id>` 的通知;
|
||||||
|
- push-server consumer 查询 `push_devices?merchant_id=eq.<recipient_id>&is_active=eq.true` 时结果为空;
|
||||||
|
- 最终落为 `send_status=no-targets`。
|
||||||
|
|
||||||
|
补充:如果你的 push-server 版本仍在用 `push_devices?on_conflict=cid` 做 upsert,也会导致“注册接口返回 ok,但 Supabase 表里没写进去”(PostgREST 会提示没有匹配的唯一约束)。
|
||||||
|
|
||||||
|
**处理建议**
|
||||||
|
- 让商家端也调用注册接口,并在请求体里带上 `merchant_id`(建议同时传 `appid`,默认为 `default`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7301/api/v1/push/register \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"cid":"<CID>","merchant_id":"<MERCHANT_UUID>","platform":"android","appid":"default"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 验证是否写入成功:
|
||||||
|
- `GET /api/v1/push/devices?merchant_id=<MERCHANT_UUID>&active=true`(优先读 Supabase)
|
||||||
|
- 或直接在数据库查:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select id, appid, cid, user_id, merchant_id, is_active, last_seen_at
|
||||||
|
from public.push_devices
|
||||||
|
where merchant_id = '<MERCHANT_UUID>'
|
||||||
|
order by last_seen_at desc nulls last;
|
||||||
|
```
|
||||||
|
|
||||||
|
- push-server consumer 对 merchant 的设备查询路径(REST)是:
|
||||||
|
- `push_devices?merchant_id=eq.<recipient_id>&is_active=eq.true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 端到端自检清单(最短路径)
|
||||||
|
|
||||||
|
1) 触发 webhook 测试:
|
||||||
|
- `node pages/mall/delivery/webhook-server/test-send.js`
|
||||||
|
|
||||||
|
2) 查队列:
|
||||||
|
- `notify_queue` 最新应为 `process_status=queued` 且 `last_error=null`
|
||||||
|
|
||||||
|
3) 查通知:
|
||||||
|
- `express_notifications` 最新应生成 `aud=user` 与 `aud=merchant` 两条
|
||||||
|
- `aud=user` 通常应推进到 `send_status=success`
|
||||||
|
|
||||||
|
4) 查设备:
|
||||||
|
- `push_devices` 中对应 `user_id`/`merchant_id` 必须存在 `is_active=true` 的 `cid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.1 Windows 一键启动三服务(建议)
|
||||||
|
|
||||||
|
为了保证与联调时一致的效果,推荐用脚本一次性启动 3 个后台进程(并落日志):
|
||||||
|
|
||||||
|
1) 启动(会默认释放 7201/7301 端口占用):
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File .\server\scripts\start-delivery-backend.ps1`
|
||||||
|
|
||||||
|
2) 触发与联调一致的 webhook 测试(打到本机 7201):
|
||||||
|
- `node .\pages\mall\delivery\webhook-server\test-send.js`
|
||||||
|
|
||||||
|
3) 停止:
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File .\server\scripts\stop-delivery-backend.ps1`
|
||||||
|
|
||||||
|
日志文件默认在仓库根目录:
|
||||||
|
- `webhook-receiver.log/.err.log`
|
||||||
|
- `notify-worker.log/.err.log`
|
||||||
|
- `push-server.log/.err.log`
|
||||||
|
|
||||||
|
PID 文件(用于停止/排查进程)在:
|
||||||
|
- `server/.runtime/delivery-backend.pids.json`
|
||||||
|
|
||||||
|
查看当前是否仍在运行(示例):
|
||||||
|
- `Get-Content .\server\.runtime\delivery-backend.pids.json`
|
||||||
|
- `Get-Process -Id <PID>`
|
||||||
|
|
||||||
|
注意:`test-send.js` 默认请求 `http://localhost:7201/webhook/express/status`,所以必须先启动 webhook-receiver。
|
||||||
|
|
||||||
|
### 7.1.1 脚本行为与常见坑(PowerShell 5.1)
|
||||||
|
|
||||||
|
- PID 文件由 `start-delivery-backend.ps1` 写入,采用 **UTF-8 无 BOM**,避免 PS 5.1 下 `ConvertFrom-Json` 因 BOM/编码细节解析失败。
|
||||||
|
- `stop-delivery-backend.ps1` 会优先按 PID 文件停止进程,并在解析/停止异常时兜底尝试释放端口(7201/7301)。
|
||||||
|
|
||||||
|
如果你不先停止就重复启动,通常会出现:
|
||||||
|
- `EADDRINUSE`(端口占用导致启动失败)
|
||||||
|
- 多实例 worker/consumer 并发运行,导致重复消费、重复写入、重复推送(排障难度陡增)
|
||||||
|
|
||||||
|
如果 `test-send.js` 返回 `{ ok: false, message: 'waybill not found' }`:
|
||||||
|
- 说明数据库里没有 `platform_express_waybills.tracking_no = 'TEST_YT_20260206_0007'` 的运单记录。
|
||||||
|
- 处理方式:先在 `platform_express_waybills` 补一条对应 tracking_no 的运单(并关联到你的 `ml_orders`),再重试发送。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常用 SQL / 操作片段
|
||||||
|
|
||||||
|
### 8.1 重跑某条队列记录(把 failed/skipped 重置为可再次消费)
|
||||||
|
```sql
|
||||||
|
UPDATE public.notify_queue
|
||||||
|
SET processed_at = NULL,
|
||||||
|
process_status = NULL,
|
||||||
|
last_error = NULL
|
||||||
|
WHERE id = '<notify_queue.id>';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 检查 message_id 是否存在重复(创建唯一索引前)
|
||||||
|
```sql
|
||||||
|
SELECT message_id, COUNT(*)
|
||||||
|
FROM public.express_notifications
|
||||||
|
WHERE message_id IS NOT NULL
|
||||||
|
GROUP BY message_id
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 查看 `push_devices` 是否有绑定
|
||||||
|
```sql
|
||||||
|
SELECT *
|
||||||
|
FROM public.push_devices
|
||||||
|
WHERE user_id = '<user_id>' OR merchant_id = '<merchant_id>'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
184
server/消息推送文档/DELIVERY_NOTIFICATION_DATA_FLOW.md
Normal file
184
server/消息推送文档/DELIVERY_NOTIFICATION_DATA_FLOW.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# 订单发货与物流通知:全链路数据流转分析文档
|
||||||
|
|
||||||
|
本文档详细描述了本商城系统中,用户订单在发货阶段,从底层物流提供商(快递100)触发Webhook回掉,一直到最终向用户设备下发App内推送消息的全量数据流转过程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整体数据流图谱
|
||||||
|
|
||||||
|
整个数据运转被精心设计为异步且解耦的数个阶段,以确保在高并发回调或网络异常时,数据不丢失、不阻塞。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Kuaidi100 as 快递100 (Webhook)
|
||||||
|
participant Receiver as Webhook Receiver
|
||||||
|
participant Supabase as Supabase (PostgreSQL)
|
||||||
|
participant DBTrigger as 数据库触发器
|
||||||
|
participant NotifyWorker as Notify Worker
|
||||||
|
participant PushServer as Push Server
|
||||||
|
participant UniPush as 云函数/UniPush
|
||||||
|
participant UserApp as 用户App
|
||||||
|
|
||||||
|
%% 第一阶段:外部数据接入与存储
|
||||||
|
Kuaidi100->>Receiver: HTTP POST 物流状态更新
|
||||||
|
Note over Kuaidi100,Receiver: 验证签名(sign),解析参数
|
||||||
|
Receiver->>Supabase: 插入/更新 platform_express_tracking_events
|
||||||
|
Supabase-->>Receiver: 存入成功返回
|
||||||
|
Receiver-->>Kuaidi100: 200 OK 返回给快递平台
|
||||||
|
|
||||||
|
%% 第二阶段:事件触发与入队
|
||||||
|
Supabase->>DBTrigger: 触发器: event_to_queue_trigger
|
||||||
|
Note over DBTrigger: 监听到 tracking_events 写入/更新<br>(条件检查:特定物流状态等)
|
||||||
|
DBTrigger->>Supabase: 插入新任务至 notify_queue 表
|
||||||
|
|
||||||
|
%% 第三阶段:队列消费与通知生成
|
||||||
|
loop 轮询读取队列 (10秒/次)
|
||||||
|
NotifyWorker->>Supabase: 捞取 status='pending' 的任务
|
||||||
|
NotifyWorker->>Supabase: 分析业务数据 (查ml_orders等)
|
||||||
|
Note over NotifyWorker: 数据转换、格式化模板内容
|
||||||
|
NotifyWorker->>Supabase: 将生成的推送记录写入 express_notifications 表
|
||||||
|
NotifyWorker->>Supabase: 更新 notify_queue.status 为 'processed'
|
||||||
|
end
|
||||||
|
|
||||||
|
%% 第四阶段:消息分发与远端推送
|
||||||
|
loop 轮询待推送通知 (10秒/次)
|
||||||
|
PushServer->>Supabase: 捞取 push_status='pending' 的记录
|
||||||
|
PushServer->>Supabase: 结合 push_devices 表查找用户cid
|
||||||
|
PushServer->>UniPush: HTTP POST (附带云函数URL & 推送体)
|
||||||
|
Note over PushServer,UniPush: 触发 unicloud 上的云函数
|
||||||
|
UniPush->>UserApp: 发放手机系统推送通知 (通道下发)
|
||||||
|
UniPush-->>PushServer: 返回推送结果 (成功/失败)
|
||||||
|
PushServer->>Supabase: 更新 express_notifications 推送状态 (delivered/failed)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段一:外部回调接入与原始数据落库
|
||||||
|
|
||||||
|
### 1. Webhook 请求接收
|
||||||
|
- **服务组件**:`server/webhook-receiver.js` ( 基于 Node.js + Express )
|
||||||
|
- **触发源头**:快递100平台(订阅回调业务)。
|
||||||
|
- **行为过程**:
|
||||||
|
- 接收 HTTP POST 请求(默认端口 3001,路径 `POST /webhook/kuaidi100`)。
|
||||||
|
- **参数验证**:解析 `param` 数据和 `sign` 签名,比对系统中配置的 secret 确保数据安全,非假冒请求。
|
||||||
|
- **源头日志留痕**:原始的 JSON 报文会被原封不动地记录在 **`platform_express_event_raw`** 表中。这是追踪客诉(如“没收到通知”时排查是否丢件)的**第一查证现场**。
|
||||||
|
- **数据提取**:解包得到快递单号、最新物流轨迹节点、物流状态编码(如:在途、派件、签收等)。
|
||||||
|
|
||||||
|
### 2. 落入业务核心库
|
||||||
|
- 表目标:主要涉及 `platform_express_tracking_events`(物流事件记录)或 `platform_express_waybills`(运单主表)。
|
||||||
|
- **处理要求**:将接收到的非结构化或外部结构化数据,转译为本系统认可的数据类型,并 Upsert(更新或插入)入库。至此,外部调用阶段结束,返回 `200 { "result": true, "returnCode": "200", "message": "成功" }` 予以确认,这确保了外部平台不再重复推送同一条消息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段二:数据库内部事件流转(触发器驱动)
|
||||||
|
|
||||||
|
为了让核心写入逻辑与后续繁琐的通知逻辑解耦,系统利用了 PostgreSQL 强大的触发器(Trigger)能力。
|
||||||
|
|
||||||
|
### 1. 触发器监听
|
||||||
|
- **关联脚本**:`20260309_add_notify_queue_and_trigger.sql` (位于 `pages/mall/delivery/doc/需求文档/`)
|
||||||
|
- 当 `platform_express_tracking_events` 发生 `INSERT` (或特定 `UPDATE`) 时,触发底层 SQL 逻辑。
|
||||||
|
|
||||||
|
### 2. 队列填充 (`notify_queue`)
|
||||||
|
- 触发器脚本 `event_to_queue_trigger` 负责初步筛选。它可能会排除一些毫无业务通知价值的冗余轨迹节点,只抓取关键节点(例如:“正在派发”、“已签收”)。
|
||||||
|
- 把关联的业务主键(如:运单号、关联订单 ID)、事件类型,作为 JSON payload,以极快的速度 `INSERT` 进入 `notify_queue` 表,设定初始状态为 `pending`。
|
||||||
|
- **优点**:Webhook API 的响应时间不再受后续通知逻辑(查询用户组、查找文案模板)的拖累。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段三:通知生成流水线(Notify Worker 消费任务)
|
||||||
|
|
||||||
|
这是业务逻辑最密集的部分,将纯粹的“发生了一件事”转化为具体的“应该发给谁、发什么内容”的推送任务数据。
|
||||||
|
|
||||||
|
### 1. 任务抓取与消费
|
||||||
|
- **服务组件**:`server/notify-worker.js` ( 独立的 Node.js 守护进程 )
|
||||||
|
- Worker 在后台持续运行,定期(例如每 10 秒)扫描 `notify_queue` 中处于 `pending` 状态的任务项。
|
||||||
|
|
||||||
|
### 2. 业务溯源与数据拼接
|
||||||
|
- Worker 锁定任务后,从 Payload 提取运单 ID / 订单 ID。
|
||||||
|
- **关联查询**:向数据库发起查询,结合 `ml_orders` (订单表)、可能的用户属性表、商品主数据表,查询出:买家的 `user_id`,商品名称摘要等内容。
|
||||||
|
- **模版渲染**:例如,将“订单 + 运单号处于签收状态”渲染为中文文本:“您购买的商品【xxx等】已由用户签收”。
|
||||||
|
|
||||||
|
### 3. 持久化至推送队代表(加工日志留存)
|
||||||
|
- 组装完毕后,Worker 将成型的通知指令,插入到真实的通知表 `express_notifications` 中,其包括目标 `user_id`、具体的 `title`、`body`。
|
||||||
|
- 把该条新建的记录标记为 `push_status = 'pending'`。
|
||||||
|
- 最后,将 `notify_queue` 中的原始任务记录状态更新为 `processed`。如果中间报错(例如解析运单或匹配订单失败),则会标记为 **`failed`** 并带有 **`error_msg`**,此表充当了**加工阶段的完整状态日志**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段四:物理推送与结果反馈(Push Server 分发)
|
||||||
|
|
||||||
|
该阶段将上阶段准备好的具体通知内容,通过第三方通道真正发送到用户设备。
|
||||||
|
|
||||||
|
### 1. 推送目标检索
|
||||||
|
- **服务组件**:`server/push-server.js` ( 基于 Node.js + Express )
|
||||||
|
- Push Server 定期轮询 `express_notifications` 中 `push_status = 'pending'` 的新通知项。
|
||||||
|
|
||||||
|
### 2. 补齐通道标识(CID)
|
||||||
|
- 系统根据需通知的目标 `user_id`,去 `push_devices`(Client ID 表) 中查找此用户最后活跃设备的绑定的 `cid`。如果没有找到合适的 `cid`,可能直接将该通知记为 `failed` 或转为站内信处理。
|
||||||
|
|
||||||
|
### 3. 请求云函数下发 (DCloud / uniCloud 侧)
|
||||||
|
- **关联配置**:`server/config.json` 的 `CLOUD_FUNC_URL`。
|
||||||
|
- Push Server 封装目标 `cid` 列表、文章标题 (title)、摘要主体 (body)、可能的附加跳转数据(payload 数据),构建一个 HTTP POST 载荷。
|
||||||
|
- 通过网络请求抛给特定的 **DCloud 阿里云/腾讯云空间** 上部署的 **uniCloud 云函数服务**。
|
||||||
|
- **核心下发通道技术栈**:因为本商城前端使用的是 uni-app x 架构,该云函数服务内包装了 DCloud 官方提供的 **UniPush 2.0 管理端 SDK**。由其直接对接个推(Getui)和包括华米OV(华为、小米、OPPO、vivo)以及苹果(APNs)在内的各大手机硬件厂商原生的**离线下发通道**,以保证 App 无论是否在后台驻留都能收到消息。
|
||||||
|
|
||||||
|
### 4. 结果更新与下发日志跟踪
|
||||||
|
- 网络请求完成后,Push Server 会接受到云函数关于各客户端接收/下发的汇报。
|
||||||
|
- 该结果会作为**下发日志**回写至对应的 `express_notifications` 记录中:
|
||||||
|
- 如果成功,`push_status` 变为 **`delivered`**。
|
||||||
|
- 如果失败,`push_status` 变为 **`failed`**,且将具体的错误文本(例如 `"invalid cid"`,无效的通道ID)存入 **`provider_response`** 字段中。这直接揭示了**为什么下发失败**。
|
||||||
|
- **至此,一条由快递公司产生的外部轨迹更新,完美结束了内部的长尾运转,闭环成了用户手机屏幕上的一次震动提醒。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务溯源与客诉排查指南
|
||||||
|
|
||||||
|
本架构天然具备完整的数据追溯能力,所有生命周期均在数据库中永久留痕。在处理诸如“用户投诉没有收到签收推送”的问题时,运维人员可按照以下步骤依序溯源:
|
||||||
|
|
||||||
|
1. **查源头是否丢件 (`platform_express_event_raw`)**:根据快递单号检索此表,确认底层物流商是否真的推送了回调数据。如果没有记录,说明是快递公司没发/漏发。
|
||||||
|
2. **查系统是否消费失败 (`notify_queue`)**:根据单号检索,看是否存在对应记录。如果在,看 `status` 字段。如果是 `pending`,说明 `notify-worker` 服务挂了没消费;如果是 `failed`,请查看 `error_msg`。
|
||||||
|
3. **查终端是否下发失败 (`express_notifications`)**:如果前面都正常,看此表的 `push_status`。如果是 `pending` 说明云函数未执行/Push Server离线;如果是 `failed`,直接查看 `provider_response` 确认厂商阻断或CID失效的具体原因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构演进与为何不使用 Redis 的说明
|
||||||
|
|
||||||
|
针对很多大厂常见的推送后台中大量使用 Redis 的现状,本系统出于以下考量采取了**“去中间件化”的轻量级 PostgreSQL 强依赖架构**:
|
||||||
|
|
||||||
|
1. **队列平替**:大厂常将 `notify_queue` 的待推消息放在 Redis(如 List/Stream)。我们直接利用 Supabase PostgreSQL 的实体表 `notify_queue` 结合 10 秒级定时轮询 (Poll) 实现了队列。
|
||||||
|
- **优势**:数据强一致性,发生断电/宕机等灾难时消息绝对不丢;极大地节约了项目运维成本,不需要单独起 Redis 实例或关心 RDB/AOF 策略。
|
||||||
|
- **容限**:在单日处理数万至十万级流转的状态下,这类轮询对 PostgreSQL 的 IO 压力微乎其微。只有当出现“秒杀级别数十万并发群发”时,才需演进到 Redis 消息队列。
|
||||||
|
2. **设备缓存查找平替**:查找 `userId -> cid` 虽然是典型的 KV 场景,但通过 Supabase 的索引表直接查询依然能保证极低的延迟响应。
|
||||||
|
|
||||||
|
这套设计对于我们十万级规模商城是性价比极高、免维护且极其稳壮的 Enterprise-Ready (企业级可用)的轻量化方案。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统容量预期与未来性能优化指南
|
||||||
|
|
||||||
|
随着业务长期运行和单量增长,本套无中间件架构在某些极限边界上可能会暴露出其物理瓶颈。为了防患于未然,现将已知的极限边缘与预案说明如下,供未来的后端维护者参考:
|
||||||
|
|
||||||
|
### 1. 历史数据爆炸与日志表清理(DB 膨胀约束)
|
||||||
|
由于本系统利用 `platform_express_event_raw`、`notify_queue` 和 `express_notifications` 等实体表承担了主要的业务流转与日志溯源功能,它们是“只增不减”的。
|
||||||
|
- **隐患**:随着每天单量的累积,单表数据可轻易超过百万条,导致查询缓慢、索引膨胀或耗尽 Supabase 数据库的物理容量。
|
||||||
|
- **未来演进四大方案(由简到强建议)**:
|
||||||
|
1. **方案一:轻量级定时硬清理(起步期适用)**:在 Supabase 面板开启 `pg_cron`,每晚跑定时 SQL 脚本,直接 `DELETE` 掉 60 天前的历史记录。优点是零门槛实施,缺点是追溯期短且引发表碎片。
|
||||||
|
2. **方案二:冷热数据分离归档(小型商城标配)**:创建同构归档表(如 `notify_queue_archive`),每晚将旧数据从热表迁移至归档表再删热表。确保生产环境极快的同时,运维能查陈年老账。
|
||||||
|
3. **方案三:声明式表分区 Table Partitioning(高配优雅解)**:将主表按月改造成分区表层级(如 `queue_2026_01`)。业务代码零修改,删数据只需直接 `DROP TABLE` 具体月表,毫秒级释放巨大闲置空间,完全无磁盘碎片。
|
||||||
|
4. **方案四:日志流外部卸载(大厂海量终极解)**:挂载监听 Supabase Realtime Webhook 或者 PostgreSQL 的逻辑复制流(Logical Replication),将所有的日志插入和变化发往第三方 Elasticsearch 或云厂商极其廉价的 OSS 对象存储桶里长存。主事务数据库里发完件立马删空,只留运行中的活跃数据。
|
||||||
|
|
||||||
|
### 2. 避免队列发生“毒药死循环” (Poison Message)
|
||||||
|
- **隐患**:本纯 DB 驱动的队列体系未完全建立如同专业死信队列(Dead Letter Queue)那般的重试计数和隔离。如果在运行 `notify-worker` 的拼接数据逻辑中,有一条“破损订单数据”触发了未能 Catch 住的致命错(Fatal Error) 使得 Node.js 进程崩塌,进程通过守护脚本重启后,这个任务依然是 `pending`。Worker 重新抓取再次崩溃,就会形成堵死同批其他订单消息的“毒药效应”。
|
||||||
|
- **未来预案/开发原则**:在 `notify-worker.js` 中新增、修改关于订单及商品表的复杂关联解析代码时,**必须将对单条业务的独立 Try-Catch 包裹进最内层的批次 `map` 中**,失败了直接给它标记为 `failed` 并写入 `error_msg` 抛弃,**绝不能**向外层抛出导致整个批次断裂。
|
||||||
|
|
||||||
|
### 3. 排队性能上限设置 (Scale-up)
|
||||||
|
- **隐患**:当前我们提供的起服动作 (`start-delivery-backend.ps1`) 均为单线程执行(一个 Node 进程)。一分钟只能通过轮询处理规定上限条数的推送,若双十一爆发发货,可能会导致发信严重滞后(排队几小时后才收到)。
|
||||||
|
- **未来预案**:
|
||||||
|
1. 第一个手段是修改配置:适当调短轮询间隙时间 (`POLL_MS`),或加大每次查询限制 (`BATCH_SIZE`)。
|
||||||
|
2. 第二个手段是多实例并发:如果要开多个 Worker 同时跑以消化消息波峰。在做这个扩展前,必须保证通过修改 SQL 将获取队列的查询改造为 `SELECT ... FOR UPDATE SKIP LOCKED`。以此实现单条消息在数据库级别的悲观跨行抢占,防止多个 Worker 获取并重复发送同一个通知。
|
||||||
|
|
||||||
|
本设计在每个阶段都支持“断层续接”:
|
||||||
|
1. 若 Webhook receiver 崩溃,外部快递100会按照自己的退避算法发起重试,保证事件能抵达网络库。
|
||||||
|
2. 若 Notify worker 崩溃,数据库里的 `notify_queue` 会静静堆积 pending 数据,一旦恢复,Worker 会把积压数据逐步消化。
|
||||||
|
3. 若 Push server 网络故障或 云函数URL失效,`express_notifications` 依然保留 `pending`,待网络或配置恢复(如更新 `CLOUD_FUNC_URL`)后自动补偿发送。
|
||||||
460
server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md
Normal file
460
server/消息推送文档/DELIVERY_WEBHOOK_PUSH_SERVER_OVERVIEW.md
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
# 物流 Webhook 接收器 & Push Server(推送后台)总览
|
||||||
|
|
||||||
|
面向读者:需要理解“物流事件如何入库、如何触发消息、如何推送到 App”的后端/运维/联调同学。
|
||||||
|
|
||||||
|
本文只做**总结性说明**;细节请跳转到对应子文档与源码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 这两套后台分别解决什么问题?
|
||||||
|
|
||||||
|
### Webhook 接收器(webhook-receiver)
|
||||||
|
**一句话**:把第三方/承运方推过来的“物流轨迹事件”接进来,完成验签(可选)、留痕,并把事件写入 Supabase 的 `platform_express_*` 表。
|
||||||
|
|
||||||
|
它关注的是:
|
||||||
|
- Webhook 请求能否稳定接收(可公网暴露、可控验签)
|
||||||
|
- 原始 payload 是否可追溯(用于审计/排障)
|
||||||
|
- 轨迹事件是否能归一化写入(供业务查询、消息生成)
|
||||||
|
- 运单摘要状态是否能更新(用于页面“当前状态”展示)
|
||||||
|
|
||||||
|
### Push Server(push-server)
|
||||||
|
**一句话**:维护“账号 ↔ 设备 CID”的映射,并提供“消息入队 + 消费下发”能力,把通知可靠地投递到云函数(由云函数完成 uni-push2 真实下发)。
|
||||||
|
|
||||||
|
它关注的是:
|
||||||
|
- 设备 CID 的注册/解绑与存储(本地 JSON 回退 + Supabase 表)
|
||||||
|
- 生成推送任务(写入 `express_notifications`)
|
||||||
|
- 消费推送任务并调用 `CLOUD_FUNC_URL`(重试/回写状态)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.1 用什么做的(技术栈/依赖)
|
||||||
|
|
||||||
|
这两套服务都是“轻量 Node.js HTTP 服务”,共同特点:
|
||||||
|
- **运行时**:Node.js(建议 18+;你当前环境是 Node.js 22 也可以)
|
||||||
|
- **HTTP 框架**:Express
|
||||||
|
- **JSON 解析**:body-parser
|
||||||
|
- **与 Supabase 通信**:直接调用 PostgREST REST API(`$SUPA_URL/rest/v1/...`),通过 `apikey`(可选 Bearer)鉴权
|
||||||
|
- **配置加载**:复用 `server/load-config.js`,支持 `.env` / `.json` / `CONFIG_FILE` 指定
|
||||||
|
|
||||||
|
差异点:
|
||||||
|
- webhook-receiver:额外用 `crypto` 做可选 HMAC 验签;主要写 `platform_express_*` 三表
|
||||||
|
- push-server:额外包含“消费者轮询 + 重试退避 + 调用云函数”的逻辑;本地 JSON 作为设备表回退(`server/data/push_devices.json`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 整体链路(从物流事件到手机通知)
|
||||||
|
|
||||||
|
建议把链路理解为 3 段:
|
||||||
|
|
||||||
|
1) **事件入库段**(Webhook 接收器负责)
|
||||||
|
- 第三方回调 → webhook-receiver → 写入/更新:
|
||||||
|
- `platform_express_event_raw`(原始留痕)
|
||||||
|
- `platform_express_tracking_events`(归一化事件事实表)
|
||||||
|
- `platform_express_waybills`(运单摘要 current_status_*)
|
||||||
|
|
||||||
|
2) **消息生成段**(通常是“事件处理器/任务/触发器”,不在这两个服务里)
|
||||||
|
- 触发器把 `platform_express_tracking_events` 的关键状态事件写入队列表 `notify_queue`
|
||||||
|
- 常驻 `notify-worker` 消费 `notify_queue`,按推送策略(关键状态、去噪、幂等)生成“消息中心记录/推送任务”
|
||||||
|
- 写入 `express_notifications`(push-server consumer 会复用该表进行下发)
|
||||||
|
|
||||||
|
相关实现(仓库内):
|
||||||
|
- 迁移脚本:`pages/mall/delivery/doc/需求文档/20260309_add_notify_queue_and_trigger.sql`
|
||||||
|
- 常驻 worker:`server/notify-worker.js`
|
||||||
|
|
||||||
|
3) **推送下发段**(Push Server + 云函数负责)
|
||||||
|
- push-server consumer 读取 `express_notifications` 的待处理记录
|
||||||
|
- 找到目标设备(`push_devices` 中的活跃 CID)
|
||||||
|
- 对每个 CID POST 到 `CLOUD_FUNC_URL`
|
||||||
|
- 云函数内部调用 uni-push2 下发 → App 收到通知
|
||||||
|
|
||||||
|
一个简化示意图:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Third[第三方/承运方] -->|POST webhook| WH[webhook-receiver :7201]
|
||||||
|
WH --> RAW[(platform_express_event_raw)]
|
||||||
|
WH --> EV[(platform_express_tracking_events)]
|
||||||
|
WH --> WB[(platform_express_waybills)]
|
||||||
|
|
||||||
|
EV --> TRG[DB Trigger]
|
||||||
|
TRG --> Q[(notify_queue)]
|
||||||
|
Q --> W[notify-worker]
|
||||||
|
W --> N[(express_notifications)]
|
||||||
|
|
||||||
|
App[App 客户端] -->|注册CID| PS[push-server :7301]
|
||||||
|
PS --> D[(push_devices)]
|
||||||
|
|
||||||
|
PS -->|轮询待发通知| N
|
||||||
|
PS -->|POST| CF[CLOUD_FUNC_URL 云函数]
|
||||||
|
CF --> UP[uni-push2]
|
||||||
|
UP --> App
|
||||||
|
```
|
||||||
|
|
||||||
|
参考(更完整的业务口径与隐私约束):
|
||||||
|
- `pages/mall/delivery/doc/需求文档/物流消息推送方案_用户端与商家端.md`
|
||||||
|
- `pages/mall/delivery/doc/需求文档/推送与设备需求文档.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Webhook 接收器(webhook-receiver)
|
||||||
|
|
||||||
|
### 3.1 位置与入口
|
||||||
|
- 代码:`pages/mall/delivery/webhook-server/webhook-receiver.js`
|
||||||
|
- 说明文档:`pages/mall/delivery/webhook-server/README.md`
|
||||||
|
|
||||||
|
### 3.2 对外接口
|
||||||
|
- `POST /webhook/express/status`
|
||||||
|
- 作用:接收轨迹变更回调
|
||||||
|
- 返回:
|
||||||
|
- 成功:`{ ok: true }`
|
||||||
|
- 运单不存在(不算系统错误):`{ ok: false, message: 'waybill not found' }`(HTTP 200)
|
||||||
|
- Supabase 鉴权失败等:HTTP 502 + `{ ok:false, message:'...' }`
|
||||||
|
- `GET /health`:健康检查
|
||||||
|
|
||||||
|
### 3.3 写库行为(核心表)
|
||||||
|
接收器在一次 webhook 调用中做三件事(顺序上:先留痕,再归一化,再更新摘要):
|
||||||
|
|
||||||
|
1) **原始留痕**:插入 `platform_express_event_raw`
|
||||||
|
- 保存:carrier、tracking_no、body(原始 payload)、received_at、signature_valid 等
|
||||||
|
|
||||||
|
2) **定位运单**:在 `platform_express_waybills` 中查找对应记录
|
||||||
|
- 优先按 `tracking_no`,其次按 `order_no`
|
||||||
|
- 找不到时返回 `{ ok:false, message:'waybill not found' }`
|
||||||
|
|
||||||
|
3) **写入事件 & 更新运单摘要**
|
||||||
|
- 将第三方的状态字段映射到平台统一的 `status_code`(如 `OUT_FOR_DELIVERY/DELIVERED/EXCEPTION/...`)
|
||||||
|
- `PATCH platform_express_waybills`:更新 `current_status_code/current_status_text/last_synced_at`
|
||||||
|
- `INSERT platform_express_tracking_events`:写入归一化事件事实数据(包含 raw_payload、dedupe_key 等)
|
||||||
|
|
||||||
|
> 当前实现的 `dedupe_key` 使用稳定哈希(基于 tracking_no / carrier / status_code / event_time / event_code / event_text 的截断组合),用于保证 webhook 重复回调不会重复写入事件表,从而避免重复入队/重复通知。
|
||||||
|
|
||||||
|
### 3.4 验签(可选)
|
||||||
|
- 配置 `WEBHOOK_SECRET` 后会校验:
|
||||||
|
- `X-Timestamp`、`X-Signature`
|
||||||
|
- 签名算法:`HMAC-SHA256(secret, rawBodyText + timestamp)`,输出 hex(务必使用**原始请求体文本**参与计算)
|
||||||
|
- 默认行为:验签失败不会直接拒绝入库(会在 raw 表记录 `signature_valid=false`)。
|
||||||
|
- 可选严格模式:设置 `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 后,验签失败将直接返回 HTTP 401。
|
||||||
|
|
||||||
|
### 3.5 配置与启动方式
|
||||||
|
接收器会先通过 `server/load-config.js` 把配置注入 `process.env`,优先级是:
|
||||||
|
1) 系统环境变量
|
||||||
|
2) `CONFIG_FILE/CONFIG_PATH` 指定的文件
|
||||||
|
3) 接收器同目录的 `webhook.config.json`
|
||||||
|
4) `server/.env` → `server/config.json` → `server/config.json.example`
|
||||||
|
|
||||||
|
常用环境变量:
|
||||||
|
- `SUPA_URL`(必需)
|
||||||
|
- `SUPA_KEY`(必需,建议使用 service_role,仅后端使用)
|
||||||
|
- `PORT`(默认 7201)
|
||||||
|
- `SUPA_USE_BEARER`(默认 false)
|
||||||
|
- `WEBHOOK_SECRET`(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Push Server(push-server)
|
||||||
|
|
||||||
|
### 4.1 位置与入口
|
||||||
|
- 代码:`server/push-server.js`
|
||||||
|
- 说明文档(偏“可运行/可运维”):
|
||||||
|
- `server/PUSH_SERVER_README.md`
|
||||||
|
- `server/README.md`
|
||||||
|
|
||||||
|
### 4.2 核心能力
|
||||||
|
1) **设备 CID 管理**
|
||||||
|
- `POST /api/v1/push/register`:注册/更新设备(写本地 JSON,并尝试 upsert 到 `push_devices`)
|
||||||
|
- `POST /api/v1/push/unregister`:解绑/置 inactive
|
||||||
|
- `GET /api/v1/push/devices`:列出设备(优先 Supabase)
|
||||||
|
|
||||||
|
2) **推送下发(云函数模式)**
|
||||||
|
- `POST /api/v1/push/send`:
|
||||||
|
- 直接按 `cids` 或 `user_id` 发送(对每个 CID 调用 `CLOUD_FUNC_URL`)
|
||||||
|
|
||||||
|
3) **通知入队 + 消费者轮询下发**
|
||||||
|
- `POST /api/v1/notifications`:写入 `express_notifications`(排队)
|
||||||
|
- consumer(可选启用):
|
||||||
|
- 定时轮询 `express_notifications` 的待处理记录
|
||||||
|
- 取到记录后查询目标 CID 列表
|
||||||
|
- 逐个调用 `CLOUD_FUNC_URL`,并回写状态/错误/重试次数
|
||||||
|
|
||||||
|
> 备注:本仓库当前为“仅云函数模式”,push-server 自身不直接对接 uni-push;真实推送逻辑在云函数里。
|
||||||
|
|
||||||
|
### 4.3 数据依赖(Supabase 表)
|
||||||
|
- `public.push_devices`
|
||||||
|
- 存储 `cid ↔ user_id/merchant_id`、`is_active` 等
|
||||||
|
- `public.express_notifications`
|
||||||
|
- 存储待发通知、状态、重试信息等(用于消息中心/推送队列)
|
||||||
|
|
||||||
|
字段语义(重要,避免联调误判):
|
||||||
|
- `status_code`:物流/业务状态(例如 `OUT_FOR_DELIVERY/DELIVERED/EXCEPTION`),由 webhook→tracking_events→notify-worker 生成。
|
||||||
|
- `send_status`:投递处理状态(`null`=待发送,`processing/retrying/success/failed/no-targets`),由 push-server consumer 读写。
|
||||||
|
|
||||||
|
迁移脚本:
|
||||||
|
- 新环节(入队+worker):`pages/mall/delivery/doc/需求文档/20260309_add_notify_queue_and_trigger.sql`
|
||||||
|
- 字段拆分(consumer 必需):`pages/mall/delivery/doc/需求文档/20260309_add_express_notifications_send_status.sql`
|
||||||
|
|
||||||
|
迁移脚本参考:`pages/mall/delivery/doc/需求文档/20260224_add_push_devices_and_notifications.sql`
|
||||||
|
|
||||||
|
### 4.4 关键配置(env)
|
||||||
|
- `PORT`:默认 7301
|
||||||
|
- `SUPA_URL`、`SUPA_KEY`/`SERVICE_ROLE_KEY`
|
||||||
|
- `SUPA_USE_BEARER`:同 webhook-receiver,默认只发 `apikey`
|
||||||
|
|
||||||
|
消费者相关:
|
||||||
|
- `ENABLE_CONSUMER=true`(或 `CONSUMER_ENABLED=true`)
|
||||||
|
- `CONSUMER_POLL_MS`:轮询间隔(默认 2000)
|
||||||
|
- `CLOUD_FUNC_URL`:云函数 HTTP invoke 地址(必需)
|
||||||
|
- `PUSH_TOKEN`:可选鉴权透传给云函数
|
||||||
|
- 重试配置:`MAX_RETRIES / RETRY_INITIAL_MS / RETRY_FACTOR / RETRY_MAX_MS`
|
||||||
|
|
||||||
|
### 4.4.1 云函数过期/URL 变更时,改哪里?
|
||||||
|
|
||||||
|
现象:云函数链接过期或更换后,push-server 下发会出现 HTTP 4xx/5xx 或网络错误(日志里通常能看到调用 `CLOUD_FUNC_URL` 失败),并导致 `express_notifications.send_status` 长期处于 `retrying/failed`。
|
||||||
|
|
||||||
|
需要修改的配置键:
|
||||||
|
- `CLOUD_FUNC_URL`:云函数 HTTP invoke 地址(push-server consumer 与 `/api/v1/push/send` 都依赖它)
|
||||||
|
- (如云函数鉴权也变化)`PUSH_TOKEN`
|
||||||
|
|
||||||
|
配置可能存在于以下位置(按优先级理解即可):
|
||||||
|
1) **系统环境变量**(临时验证/单次启动最方便)
|
||||||
|
2) **显式配置文件**:通过 `CONFIG_FILE/CONFIG_PATH` 指定的 `.json` 或 `.env`
|
||||||
|
3) **默认配置文件**:`server/config.json`(`server/load-config.js` 会读取并注入 env)
|
||||||
|
|
||||||
|
最常用改法(推荐):修改 `server/config.json` 里的 `CLOUD_FUNC_URL`,然后重启 push-server。
|
||||||
|
|
||||||
|
PowerShell 快速验证(不改文件,先确认新 URL 可用):
|
||||||
|
```powershell
|
||||||
|
$env:CLOUD_FUNC_URL='https://new-cloudfunc.example/invoke'
|
||||||
|
node server/push-server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
改完必须做的动作:
|
||||||
|
- **重启 push-server**:`CLOUD_FUNC_URL` 在进程启动时读取,改配置后不重启不会生效。
|
||||||
|
|
||||||
|
CI/自动化(如果你用了 smoke test):
|
||||||
|
- GitHub Actions / CI 里若有云函数 smoke test,通常还需要同步更新 Secrets(例如 `CLOUD_FUNC_URL`)。
|
||||||
|
|
||||||
|
最小 smoke test(确认云函数通不通):
|
||||||
|
```powershell
|
||||||
|
curl.exe -sS -X POST "$env:CLOUD_FUNC_URL" -H "Content-Type: application/json" -d "{\"token\":\"$env:PUSH_TOKEN\",\"push_clientid\":\"<DEVICE_CID>\",\"title\":\"ping\",\"content\":\"pong\",\"payload\":{}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 常见问题(定位方向)
|
||||||
|
- Supabase 报 `PGRST301` / 401:通常是 Bearer/JWT_SECRET 不匹配导致,优先只用 `apikey`(`SUPA_USE_BEARER=false`)。
|
||||||
|
- `/api/v1/push/send` 返回 `push_clientid required`:云函数侧没有正确解析请求体(常见于 HTTP 触发器把 body 放在 `event.body`)。
|
||||||
|
- 中文变 `????`:Windows PowerShell 5.1 发送 JSON 编码问题,按 `server/PUSH_SERVER_README.md` 的 UTF-8 字节方式发送。
|
||||||
|
- 消费者不工作:检查 `ENABLE_CONSUMER`、`CLOUD_FUNC_URL` 是否配置;再查 `express_notifications` 是否有待处理记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.6 notify-worker(消息生成入队)
|
||||||
|
|
||||||
|
### 4.6.1 作用(它负责哪一段)
|
||||||
|
**一句话**:从 `notify_queue` 消费“需要生成消息的事件”,把它转换成 `express_notifications`(消息中心记录/推送任务),供 push-server consumer 后续下发。
|
||||||
|
|
||||||
|
它关注的是:
|
||||||
|
- **把事件变成消息**:把“物流事件”落成“可按 user/merchant 查询的消息记录”。
|
||||||
|
- **收件人映射**:根据运单 → 订单,解析出 `user_id` / `merchant_id`。
|
||||||
|
- **幂等入队**:对同一事件避免重复生成(靠稳定的 `message_id` upsert)。
|
||||||
|
|
||||||
|
> 边界:notify-worker **不负责推送下发**;真正调用 `CLOUD_FUNC_URL` 的是 push-server consumer。
|
||||||
|
|
||||||
|
### 4.6.2 位置与入口
|
||||||
|
- 代码:`server/notify-worker.js`
|
||||||
|
- 说明文档(更详细):`server/NOTIFY_WORKER_README.md`
|
||||||
|
|
||||||
|
### 4.6.3 核心行为(做了什么)
|
||||||
|
1) 拉取待处理队列:`notify_queue?processed_at=is.null`(按 `created_at` 升序,批量处理)。
|
||||||
|
2) 解析业务上下文:
|
||||||
|
- 查 `platform_express_waybills` 取 `order_id/order_no, tracking_no, carrier`
|
||||||
|
- 查 `ml_orders` 取 `user_id, merchant_id`
|
||||||
|
3) 为每个受众写消息:
|
||||||
|
- 若 `user_id` 存在:写 `aud='user'`
|
||||||
|
- 若 `merchant_id` 存在:写 `aud='merchant'`
|
||||||
|
- 写入方式:upsert 到 `express_notifications`(`on_conflict=message_id`),避免重复。
|
||||||
|
4) 回写队列处理结果:把 `notify_queue.processed_at` 置上,并写 `process_status/last_error`(queued/skipped/failed)。
|
||||||
|
|
||||||
|
### 4.6.4 数据依赖(读/写哪些表)
|
||||||
|
- 读取:`notify_queue`、`platform_express_waybills`、`ml_orders`
|
||||||
|
- 写入:`express_notifications`
|
||||||
|
|
||||||
|
关键字段口径:
|
||||||
|
- `notify_queue.dedupe_key`:应尽量稳定(同一事件重复到达也一致),否则会造成重复消息。
|
||||||
|
- `express_notifications.message_id`:脚本按 `aud|waybill_id|dedupe_key` 计算 hash 生成,用于幂等。
|
||||||
|
|
||||||
|
### 4.6.5 配置与运行(最小要点)
|
||||||
|
配置加载与 webhook/push-server 一致(复用 `server/load-config.js`),并支持同目录 `server/notify-worker.config.json`。
|
||||||
|
|
||||||
|
常用环境变量:
|
||||||
|
- `SUPA_URL`(必需)
|
||||||
|
- `SERVICE_ROLE_KEY` 或 `SUPA_KEY`(必需,推荐 service_role)
|
||||||
|
- `SUPA_USE_BEARER`(可选,默认 false)
|
||||||
|
- `NOTIFY_WORKER_POLL_MS`(默认 2000)
|
||||||
|
- `NOTIFY_WORKER_BATCH_SIZE`(默认 20)
|
||||||
|
- `RUN_ONCE=true`(只跑一轮便退出,适合验证)
|
||||||
|
|
||||||
|
启动示例(PowerShell):
|
||||||
|
```powershell
|
||||||
|
node server/notify-worker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6.6 常见问题(定位方向)
|
||||||
|
- 队列一直堆积:看 `notify-worker.err.log`/控制台错误;重点检查 `SUPA_URL/SERVICE_ROLE_KEY` 是否正确、表/字段是否存在。
|
||||||
|
- 都是 skipped:通常是运单找不到订单(`platform_express_waybills` 缺 `order_id/order_no` 或 `ml_orders` 查不到)。
|
||||||
|
- 重复消息:检查上游写入 `notify_queue.dedupe_key` 是否稳定(避免 `Date.now()` 这种随机值)。
|
||||||
|
- RLS/403:优先使用 `SERVICE_ROLE_KEY`,并确保 PostgREST 有权限访问相关表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 给联调/运维的“最小心智模型”
|
||||||
|
|
||||||
|
- Webhook-receiver 只负责:**接收** & **入库**(`platform_express_*`)。
|
||||||
|
- Push-server 只负责:**设备表** & **通知队列** & **调用云函数**(`push_devices/express_notifications`)。
|
||||||
|
- 中间那段“什么时候该推、推给谁、推什么文案/脱敏后字段”通常在**事件处理器/业务服务**里实现(或数据库触发器 + 消费者)。
|
||||||
|
|
||||||
|
把这三段拆开,排障会非常快:
|
||||||
|
1) webhook 是否收到?(看 webhook-receiver 日志 / raw 表)
|
||||||
|
2) tracking_events 是否写入?(看事件表)
|
||||||
|
3) notifications 是否生成?(看 express_notifications)
|
||||||
|
4) push-server 是否消费?(看 push-server 日志 / 重试字段)
|
||||||
|
5) 云函数是否下发成功?(看云函数日志 / uni-push 返回)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.1 一页摘要
|
||||||
|
|
||||||
|
### 业务价值(为什么要做)
|
||||||
|
- **提升履约感知**:物流关键节点及时触达(用户端/商家端),减少客服咨询与退款/纠纷风险。
|
||||||
|
- **可追溯**:Webhook 原始留痕 + 事件事实表 + 消息队列表,出了问题能定位“卡在哪一段”。
|
||||||
|
- **解耦与可演进**:Webhook 入库、消息生成、推送下发三段解耦,后续可替换推送通道/触发方式而不重写全链路。
|
||||||
|
|
||||||
|
### 范围边界(做了什么 / 没做什么)
|
||||||
|
- **已覆盖**:第三方物流事件接收入库;设备 CID 注册与管理;通知入队与消费;调用云函数(由云函数实际对接 uni-push2)。
|
||||||
|
- **不在本两服务内**:消息生成策略(哪些事件要推、文案/脱敏、收件人映射、幂等 dedupe_key 规则)通常由“事件处理器/业务服务/任务”负责。
|
||||||
|
|
||||||
|
### 外部依赖(上线前必须明确)
|
||||||
|
- **Supabase/Postgres**:两服务都依赖 REST 读写(需要稳定网络、正确的 key 权限、相关表已迁移)。
|
||||||
|
- **云函数**:push-server 依赖 `CLOUD_FUNC_URL` 可用;云函数内部需能成功调用 uni-push2。
|
||||||
|
- **第三方/承运方**:Webhook 接入参数、验签口径、回调重试策略需要对齐。
|
||||||
|
|
||||||
|
### 风险与控制点
|
||||||
|
- **密钥风险**:`service_role key` 属高权限,必须只在服务器环境变量中使用;严禁进入前端/日志/截图。
|
||||||
|
- **隐私合规**:raw 表与 payload 可能含敏感字段,必须限制读取权限;推送内容只允许“脱敏/清洗后的摘要”。
|
||||||
|
- **可靠性**:Webhook 可重复/乱序;需要稳定的幂等策略(事件 dedupe_key、消息 dedupe_key)避免重复消息与状态回退。
|
||||||
|
- **可用性**:push-server consumer 失败要可重试、可观测、可告警;云函数故障不应影响主业务写库。
|
||||||
|
|
||||||
|
### 运维成本(需要投入多少人/怎么管)
|
||||||
|
- **部署形态**:两个常驻 Node 进程(端口默认 webhook 7201、push 7301),建议用进程守护(Windows 服务 / pm2 / docker / supervisor 任选其一)。
|
||||||
|
- **日志与排障**:至少保留 7~30 天日志;能按 request_id/message_id/tracking_no 追踪。
|
||||||
|
|
||||||
|
### 建议监控指标(可用来验收与周报)
|
||||||
|
- Webhook:每分钟请求量、2xx 比例、验签失败率、写库失败率、waybill not found 比例。
|
||||||
|
- 推送:待处理队列长度(pending/retrying 数)、每分钟消费量、成功率、平均重试次数、失败 Top 原因(HTTP 非 2xx / 超时 / 云函数业务 errCode)。
|
||||||
|
- 端到端:从事件入库到推送送达的 P50/P95 延迟(分钟级即可)。
|
||||||
|
|
||||||
|
### 责任分工(出现问题找谁)
|
||||||
|
- webhook-receiver:对“第三方回调接入、验签、入库”负责。
|
||||||
|
- 事件处理器/业务服务:对“消息生成规则、脱敏文案、收件人映射、幂等策略”负责。
|
||||||
|
- push-server + 云函数:对“队列消费、重试、推送通道调用成功率”负责。
|
||||||
|
|
||||||
|
### 上线检查清单(最小版)
|
||||||
|
1) 表结构已迁移(`platform_express_*`、`push_devices`、`express_notifications`)。
|
||||||
|
2) 密钥仅在服务器环境变量,日志不打印敏感值。
|
||||||
|
3) webhook `/health` 正常;能写 raw 与 events;运单状态能更新。
|
||||||
|
4) push-server `/health` 正常;能注册设备;能入队;consumer 能调用云函数并回写状态。
|
||||||
|
5) 监控与告警已配置(至少:连续失败、队列堆积、云函数不可达)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 联调/运维速记(2026-03-10)
|
||||||
|
|
||||||
|
下面是本仓库在“webhook → 入库 → 入队 → notify-worker 生成通知 → push-server consumer 调云函数推送到手机”端到端跑通时,最容易踩到的坑与对应结论(只写要点;细节见排障文档)。
|
||||||
|
|
||||||
|
更详细的排障与 SQL 清单:`docs/DELIVERY_E2E_TROUBLESHOOTING_20260310.md`
|
||||||
|
|
||||||
|
### 5.2.1 最常见的故障与根因(按出现频率)
|
||||||
|
|
||||||
|
1) **notify-worker 报 “order not found for waybill” 但数据库明明有订单**
|
||||||
|
- 典型根因:`ml_orders` 开了 RLS,PostgREST 用 `apikey` 查询会返回空数组(看起来像“没数据”)。
|
||||||
|
- 处理建议:生产上用 `SERVICE_ROLE_KEY` 或服务端 JWT + 正确策略;联调阶段可以临时放开/调整策略以确认链路。
|
||||||
|
|
||||||
|
2) **写 `express_notifications` upsert 报 `42P10`(ON CONFLICT 找不到唯一约束)**
|
||||||
|
- 典型根因:表上是“部分唯一索引(partial unique index)”,而 PostgREST 的 `on_conflict=message_id` 只能匹配“普通 UNIQUE 约束/索引”。
|
||||||
|
- 处理建议:确保存在普通唯一索引:`UNIQUE(message_id)`(不要带 WHERE 条件)。
|
||||||
|
|
||||||
|
3) **push-server 启动失败:`EADDRINUSE` / 7301 端口被占用**
|
||||||
|
- 典型根因:旧进程未退出、或端口被其它程序占用。
|
||||||
|
- 处理建议:先释放端口再启动;一键启动脚本会自动尝试清理 7201/7301。
|
||||||
|
|
||||||
|
4) **日志显示云函数调用 success,但手机不弹通知**
|
||||||
|
- 典型根因:下发的 `content/body` 为空或过短,在某些系统/机型上会被折叠为“不展示”。
|
||||||
|
- 处理建议:确保推送请求里至少有稳定的 `title` + `content`(本仓库已在 consumer 侧做了 content 兜底)。
|
||||||
|
|
||||||
|
5) **`send_status=no-targets`(找不到目标设备)**
|
||||||
|
- 典型根因:`push_devices` 里没有对应 `user_id/merchant_id` 的活跃 `cid`(未注册/已注销/绑定字段为空)。
|
||||||
|
- 处理建议:先用 App 触发注册接口写入设备表;若要商家端收推送,必须正确绑定 `merchant_id`。
|
||||||
|
|
||||||
|
### 5.2.2 Windows 下一键启动/停止(后台常驻)
|
||||||
|
|
||||||
|
为方便“可运维 + 可自启动”,仓库提供了 PowerShell 脚本把 3 个常驻进程统一拉起/停掉:
|
||||||
|
|
||||||
|
- 启动(会尽量释放 7201/7301,并把进程 PID 写入 runtime 文件):
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\server\scripts\start-delivery-backend.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 停止(按 PID 文件停止进程):
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\server\scripts\stop-delivery-backend.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- PID 文件位置(便于运维查进程/做自启接入):
|
||||||
|
- `server/.runtime/delivery-backend.pids.json`
|
||||||
|
|
||||||
|
> 说明:脚本按自身路径定位仓库 root,因此可以在任意工作目录执行;并会按 `server/load-config.js` 的规则加载配置(最常见是 `server/config.json`)。`CLOUD_FUNC_URL` 等配置变更后需重启进程才会生效。
|
||||||
|
|
||||||
|
**脚本兼容性说明(Windows PowerShell 5.1)**
|
||||||
|
|
||||||
|
- `start-delivery-backend.ps1` 会把 PID 文件写成 **UTF-8 无 BOM**,避免 `ConvertFrom-Json` 在 5.1 下因 BOM/编码细节解析失败。
|
||||||
|
- `stop-delivery-backend.ps1` 会:
|
||||||
|
- 优先按 PID 文件停止进程,并删除 PID 文件;
|
||||||
|
- 解析异常时也会兜底尝试释放端口(7201/7301),避免“端口占用导致下次启动失败”。
|
||||||
|
|
||||||
|
**如果不停止会怎么样**
|
||||||
|
|
||||||
|
- 常见现象:下次启动报 `EADDRINUSE`(7201/7301 端口被旧进程占用)。
|
||||||
|
- 风险:重复启动多个 `notify-worker`/consumer,可能造成重复消费、重复写消息/推送,排障会非常混乱。
|
||||||
|
- 风险:旧进程仍在用旧配置/旧代码,你改了配置但看起来“不生效”。
|
||||||
|
|
||||||
|
### 5.2.3 快速复现(和联调一致的 E2E)
|
||||||
|
|
||||||
|
1) 先启动后台(见上节)。
|
||||||
|
2) 触发 webhook 测试请求:
|
||||||
|
```powershell
|
||||||
|
node .\pages\mall\delivery\webhook-server\test-send.js
|
||||||
|
```
|
||||||
|
3) 观察链路:
|
||||||
|
- webhook-receiver 日志是否收到请求;若返回 `{ ok:false, message:'waybill not found' }`,先补齐 `platform_express_waybills` 对应运单。
|
||||||
|
- notify-worker 是否把队列变成 `express_notifications`。
|
||||||
|
- push-server consumer 是否把 `send_status` 推进到 `success`(或 `no-targets/retrying/failed`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 进一步阅读(从“总览”到“可落地”)
|
||||||
|
|
||||||
|
- 数据流详细流转图谱与分析:`server/消息推送文档/DELIVERY_NOTIFICATION_DATA_FLOW.md`
|
||||||
|
- webhook-receiver:`pages/mall/delivery/webhook-server/README.md`
|
||||||
|
- push-server(运行与变更记录):`server/PUSH_SERVER_README.md`
|
||||||
|
- notify-worker(消息生成入队):`server/NOTIFY_WORKER_README.md`
|
||||||
|
- push-server(消费者与云函数约定):`server/README.md`
|
||||||
|
- 配置加载器:`server/load-config.js`
|
||||||
|
- 业务方案与隐私口径:
|
||||||
|
- `pages/mall/delivery/doc/需求文档/物流消息推送方案_用户端与商家端.md`
|
||||||
|
- `pages/mall/delivery/doc/需求文档/推送与设备需求文档.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 安全提醒(强烈建议)
|
||||||
|
|
||||||
|
- `SUPA_KEY/service_role key` 只允许在后端/服务器环境使用,**严禁下发到前端**。
|
||||||
|
- 如需把 push-server 的发送/入队接口对外开放,建议至少加一层鉴权(Bearer token、IP 白名单或内网访问)。
|
||||||
|
- `platform_express_event_raw.body/raw_payload` 可能包含敏感信息,生产上建议严格控制读取权限并记录审计。
|
||||||
80
server/消息推送文档/DEPLOY_WORKFLOW.md
Normal file
80
server/消息推送文档/DEPLOY_WORKFLOW.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
**部署工作流(打包 → 上传/发布 → smoke‑test)**
|
||||||
|
|
||||||
|
- **目的**: 描述如何把本地 `uniCloud` 云函数项目自动化打包、上传并发布为可供后端调用的云函数;说明两种上传方式(直接调用上传 API / 使用 `unicloud` CLI)与 smoke‑test 验证流程;给出 CI 集成与常见故障排查建议。
|
||||||
|
|
||||||
|
- **目录结构(示例)**:
|
||||||
|
- `uniCloud-alipay/cloudfunctions/<funcName>/` - 云函数源码目录(含 `index.js`、`package.json` 等)
|
||||||
|
- `server/tools/deploy-cloudfunc.js` - 打包并上传/发布的脚本
|
||||||
|
- `server/dist/` - 输出的 zip 文件目录
|
||||||
|
|
||||||
|
1) 打包
|
||||||
|
|
||||||
|
- 使用脚本 `server/tools/deploy-cloudfunc.js` 的默认行为:把指定云函数目录压缩为 zip(例如 `dist/testUnipush2.zip`)。
|
||||||
|
- 本地快速命令:
|
||||||
|
```bash
|
||||||
|
# 只打包(不上传)
|
||||||
|
node server/tools/deploy-cloudfunc.js --dir path/to/your/cloudfunctions/func
|
||||||
|
```
|
||||||
|
|
||||||
|
2) 上传 / 发布(两种方式,任选其一)
|
||||||
|
|
||||||
|
- A. 使用提供商“上传/发布”HTTP API(推荐用于无 CLI 的环境)
|
||||||
|
- 要求:提供上传 URL(CLOUD_UPLOAD_URL 或 `--upload`)、必要的 token(CLOUD_UPLOAD_TOKEN)和可能的额外表单字段(例如 appId)。
|
||||||
|
- 流程:脚本把 zip 作为 `multipart/form-data` POST 到上传端点;若上传端点返回仅是“已接收”而非“已发布”,还需调用发布/部署 API(CLOUD_DEPLOY_API 或 `--deployApi`)。
|
||||||
|
- 注意:不要把 zip 发到函数的 invoke(调用)URL(例如 `/test`)。如果你把文件 POST 到 invoke URL,平台会把请求当作函数执行并返回业务错误(例如 `{"errCode":400,"errMsg":"push_clientid required"}`)。脚本内已添加检测,当响应看起来像 invoke 返回会立即报错并提示。
|
||||||
|
|
||||||
|
- B. 使用 `unicloud` CLI(简单且与 HBuilder 官方流程一致)
|
||||||
|
- 前提:机器/CI 能安装并执行 `unicloud`,并已登录或有可用凭证。常用命令示例:
|
||||||
|
```bash
|
||||||
|
unicloud upload -p <SPACE_ID> -f <FUNCTION_NAME> ./uniCloud-alipay/cloudfunctions/<FUNCTION_NAME>
|
||||||
|
```
|
||||||
|
- 优点:不需要逆向抓包,等同于控制台发布,适合 CI。缺点:需在 CI 中安装 CLI 并处理登录。
|
||||||
|
- 建议:在脚本中添加 CLI fallback,当未提供 `--upload` 时尝试调用 `unicloud upload`(脚本可接收 `--spaceId` 或环境变量 `CLOUD_UNICLOUD_SPACEID`)。
|
||||||
|
|
||||||
|
3) smoke‑test(发布后验证)
|
||||||
|
|
||||||
|
- 在上传/发布成功后,应通过函数的 invoke URL 做一次简单调用,验证函数行为(例如发送测试推送或返回 expected response)。脚本支持传入 `--invokeUrl` / `--funcInvokeUrl` 与 `--testCid` 做自动验证。
|
||||||
|
- 若 smoke‑test 失败:检查是否为 invoke URL 被误用为 upload URL、是否发布尚未完成、或权限/鉴权(token)问题。
|
||||||
|
|
||||||
|
4) CI 集成(GitHub Actions 示例思路)
|
||||||
|
|
||||||
|
- 步骤:
|
||||||
|
1. Checkout 代码
|
||||||
|
2. 安装依赖(若使用脚本中的 archiver/form-data/node-fetch)
|
||||||
|
3. 若使用 CLI:安装 `unicloud` 并通过 secrets 登录(或在 runner 上提前配置 service account)
|
||||||
|
4. 运行 `node server/tools/deploy-cloudfunc.js --upload ${{ secrets.CLOUD_UPLOAD_URL }} --invokeUrl ${{ secrets.CLOUD_FUNC_URL }} --testCid ${{ secrets.TEST_DEVICE_CID }}` 或运行 CLI fallback
|
||||||
|
5. 检查返回码并在失败时标记 Job 失败
|
||||||
|
|
||||||
|
5) 必要环境变量与参数
|
||||||
|
|
||||||
|
- CLOUD_UPLOAD_URL / --upload : 上传 API URL(非函数 invoke URL)
|
||||||
|
- CLOUD_UPLOAD_TOKEN : 上传时使用的 Bearer token(或其它鉴权)
|
||||||
|
- CLOUD_DEPLOY_API / --deployApi : 若上传后需单独调用发布 API,可传入
|
||||||
|
- CLOUD_FUNC_URL / --invokeUrl : 发布后的函数调用 URL,用来做 smoke‑test
|
||||||
|
- TEST_DEVICE_CID / --testCid : 用于 push smoke‑test 的测试设备 CID
|
||||||
|
- CLOUD_UNICLOUD_SPACEID / --spaceId : 若使用 `unicloud` CLI 上传则为服务空间 ID
|
||||||
|
|
||||||
|
6) 常见问题与排查步骤
|
||||||
|
|
||||||
|
- 问:脚本返回 `Only absolute URLs are supported` 或 fetch 报错
|
||||||
|
- 答:通常因为 `--upload` 使用但没有提供值;请改为 `--upload <URL>` 或设置 `CLOUD_UPLOAD_URL`。
|
||||||
|
|
||||||
|
- 问:上传后看到 `{"errCode":400,"errMsg":"push_clientid required"}`
|
||||||
|
- 答:说明你把 zip POST 到了函数的 invoke URL(它把请求当作函数调用处理)。请提供正确的上传/发布 API 或使用 `unicloud upload` CLI 来发布。
|
||||||
|
|
||||||
|
- 排查建议:
|
||||||
|
- 在 HBuilderX 的发布流程中打开 Network 面板,执行一次发布并抓取对应请求(记录 URL、method、form 字段、headers),把它贴到脚本配置中或发给维护者。
|
||||||
|
- 在 PowerShell 或 Linux 上用 `curl -v` 验证上传端点返回内容,注意响应结构是否像“发布成功”而非函数执行结果。
|
||||||
|
|
||||||
|
7) 安全建议
|
||||||
|
|
||||||
|
- 把上传/发布的 token、spaceId、testCid 等放在 CI secrets 或受保护的环境变量中,避免写入代码库。
|
||||||
|
- 脚本中的自动部署入口应加鉴权(例如 `DEPLOY_BEARER`),不要在公网上暴露未经保护的上传接口。
|
||||||
|
|
||||||
|
8) 下一步(可选)
|
||||||
|
|
||||||
|
- 我可以把 `deploy-cloudfunc.js` 增加 `unicloud` CLI fallback(检测 `--spaceId` 或 `CLOUD_UNICLOUD_SPACEID` 并在没有 `--upload` 时调用 CLI),也可直接把你抓到的 HBuilder 上传请求模式接入脚本以实现完全自动化。请告知你偏好哪个方案。
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
文件位置:server/DEPLOY_WORKFLOW.md
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user