Merge remote-tracking branch 'origin/ysj-delivery'

This commit is contained in:
not-like-juvenile
2026-03-17 12:17:38 +08:00
120 changed files with 17577 additions and 673 deletions

59
.github/workflows/deploy-cloudfunc.yml vendored Normal file
View 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"

View File

@@ -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
View 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 策略(逐表)
- [ ] 前端将接单/确认调用迁移到新 APIfeature-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` 示例直接追加到本文件吗?

View File

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

View File

@@ -1,6 +1,6 @@
# 🛍️ Mall (uni-app / uvue) # 🛍️ Mall (uni-app / uvue)
本仓库为 uni-appuvue/uts商城项目包含消费者端、配送端、数据分析、管理后台、商家端、客服端等模块。 本仓库为 uni-appuvue/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` / 后台消费者流程。若需要,我可以生成迁移运行命令或提供后台消费者示例代码。

View File

@@ -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'
// 备用配置(已注释,如需切换可取消注释) // 备用配置(已注释,如需切换可取消注释)
// 开发环境 - 其他内网地址 // 开发环境 - 其他内网地址

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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"
}
} }
] ]
}, },

View File

@@ -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` 模拟账号的持久化支持。

View File

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

View File

@@ -7,6 +7,8 @@
-- - 完成配送ml_delivery_tasks.status = 5 -- - 完成配送ml_delivery_tasks.status = 5
-- - 配送时长delivered_at - assigned_at分钟 -- - 配送时长delivered_at - assigned_at分钟
-- - avg_feedelivery_fee 平均 -- - avg_feedelivery_fee 平均
-- 弃用
-- ===================================================================================== -- =====================================================================================
-- 1) 配送效率日趋势 -- 1) 配送效率日趋势

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

View File

@@ -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` 模块是一个完整的配送员工作系统,涵盖了:
- **个人管理**(资料、设置)
- **任务处理**(接单、配送、完成)
- **数据统计**(收入、评价、历史)
- **车辆管理**
结构清晰、功能完整,适合用于实际配送平台开发。
---

View 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 并实现。

View 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` 是否符合筛选条件。

View File

@@ -0,0 +1,81 @@
# 状态 15 行为与代码位置说明
本文档针对 `ml_delivery_tasks.status` 中 15 的状态,逐项列出在 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 / acceptOrderindex.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 状态更新为 3index.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 -> 5index.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` 并提交。
请选择一项继续。

View 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 实现秒级抢单逻辑与状态同步。

View File

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

View File

@@ -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()` 最好按司机服务区域与距离筛选,并使用分页/实时推送代替频繁轮询。

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

View 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添加回退 mappingsession -> 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空状态/重试按钮)。

View File

@@ -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()` 做兼容处理。

View File

@@ -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空状态/重试按钮)。

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

View File

@@ -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 天未上报)。
-- =====================================================================================

View File

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

View File

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

View File

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

View File

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

View 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. 表职责方向
保存第一手的物流商如快递100Webhook 推送过来的原生 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. 把源头触发器挂载到新的交易表中即可。此五表闭环具备极佳的**水平扩展性**与**极低的中间件运维成本**。

View File

@@ -0,0 +1,85 @@
-- =====================================================================================
-- RPC: notify-worker safe recipients lookup (RLS-safe)
--
-- 背景:
-- - public.ml_orders 已开启 RLSPostgREST 在未携带可解码 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) 读取请求头 tokenPostgREST 会把 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;

View File

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

View File

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

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

View File

@@ -0,0 +1,124 @@
# uni-push2 安卓联调与取 CID 说明
## 1. 目的与范围
本文档用于记录本项目在 **uni-app xUVUE + UTS** 下,对 DCloud **统一推送 uni-push2** 的安卓端最小化接入与验证流程,目标是完成闭环:
1) 真机运行应用
2) 获取 CIDclientId
3) 在 uni-push2 控制台使用“指定 CID”推送
4) 设备收到推送(通知栏/透传回调)
> 说明:本文聚焦“能跑通验证链路”的最小方案;厂商通道(小米/华为等)的离线推送配置属于后续增强。
## 2. 关键结论(先看这个)
- **调试基座 ≠ 你的应用**:在 HBuilderX 使用“运行到 Android 设备(标准基座)”拿到的 CID归属是“基座应用”因此在你的 uni-push2 应用里按 CID 推送会提示“CID 不存在或不属于此应用”。
- 要让 CID 归属正确,必须使用 **云打包/本地打包** 生成的 **你自己的 APK**,安装到手机后再获取 CID。
- 不依赖控制台日志获取 CID为了避免无法抓取 logcat 的情况,已实现 **把 CID 写入本地存储**,并在 minimal 页面 **直接展示 + 点击复制**
## 3. 环境说明
- 框架uni-app xUVUE + 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) 等待 1030 秒(必要时重启一次 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 同步可能有延迟,等 13 分钟再试)
## 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/通知样式与点击跳转”纳入需求范围,请在本文基础上补充对应厂商后台开通、证书参数与多通道策略。

View 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`(仅供客服/运维查看)并折叠显示。
- 时间线组件需支持展开单条事件详情、证据大图预览与来源标注(承运方/聚合方)。
---
(以上为前端字段与契约建议,实际字段名可与后端协商最终确认)

View 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 开关默认关闭,且仅在测试环境允许启用

View 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"
- 所有接口请求/响应均为 JSONContent-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 验签失败率异常、单运单长时间无更新。
## 十、示例 cURLWebhook 验证场景模拟)
```
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
定位:该文档描述“根据圆通运单号查询物流轨迹”的接口形态,典型属于“平台主动查询(轮询)”模式。
关键交互要点(按官方文档整理,具体以控制台配置与最新文档为准):
- 传输HTTPSPOST。
- 报文结构:请求体包含 `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` 故障注入)

View 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。
- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。
## 三、术语
- CIDPush 平台分配的 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

View 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。
- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。
## 三、术语
- CIDPush 平台分配的 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

View 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_*` 表。)

View 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 说明

View 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` 一并存入事件记录(或运单聚合记录),对外查询接口可返回该版本用于排查。
- 变更策略建议:
- 小改动优先“仅影响未来事件”(不回刷历史)。
- 如必须修正历史映射,采用回放/回填任务,并对已对外展示的结果做备注或审计记录,避免静默改写。
代价与取舍:
- 会引入配置治理与发布流程的管理成本;若当前映射很稳定、承运方少、无强审计诉求,可以先不启用该方案。

View 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` 升序组装时间线。

View 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-1Checklist+ P0-2场景库清单——直接提升联调效率
2) 再补 P0-3ACK/重试约定)——避免重复推送与误判
3) 再补 P0-4承运方接入配置表模板——为后续接真实承运方铺路
4) 最后补 P1 项——为线上长期治理做准备

View 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. 核心概念
- 承运方carrierYUNDA / 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 控制面 APIMock 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`

View File

@@ -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.idtask 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;

View File

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

View File

@@ -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 的 iddriver_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;

View File

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

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

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

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

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

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

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

View 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()

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

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

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

View File

@@ -0,0 +1,84 @@
**概述**
- **目标**: 让快递合作方(例如韵达)能安全、可靠地向系统的 `/webhook/express/status` 发送回调,用于运单状态同步与推送触发。
- **范围**: 包含鉴权/验签约定、幂等策略、测试样例、部署与运维建议。
**接入概览**
- **Endpoint**: `https://<your-domain>/webhook/express/status`
- **协议**: HTTPSTLS 必需)
- **数据格式**: `application/json`body 为第三方原始回调 JSON。
**鉴权与验签**
- **方式**: 每个合作方分配一份 HMAC secretUTF-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 表示服务端异常(合作方可按重试策略重试)。
- **建议重试规则**(对方遵守): 35 次,指数退避(例如 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"
```
**示例PowerShellWindows**
```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` 联调,通过 23 次真实回调验证 `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`、测试域名、示例脚本、接入说明(本文件)。
---
作者:自动生成;可根据合作方反馈补充示例与节流策略。

View 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/Kongkey-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)
作者:自动生成(可手动调整)

View 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()

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

View File

@@ -0,0 +1,6 @@
{
"SUPA_URL": "http://192.168.1.62:18000",
"SUPA_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzM2MjExNDMsImV4cCI6MTkzMTMwMTE0M30.zahVzrUCUkB436SHYyM2muGL3Lg_aocJJgWv1t6PpKg",
"WEBHOOK_SECRET": "",
"PORT": "7201"
}

View File

@@ -0,0 +1,6 @@
{
"SUPA_URL": "http://192.168.1.62:18000",
"SUPA_KEY": "PASTE_YOUR_SERVICE_ROLE_KEY_HERE",
"WEBHOOK_SECRET": "",
"PORT": "7201"
}

View File

@@ -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 = '登录失败,请重试'

View File

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

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

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

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View 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
View 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
View 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 JWTJWT_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
View 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;

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

View 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

View 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

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

View 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检测到上传目标看起来像云函数的调用invokeURL而不是上传/发布 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
View 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')
})()

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

View 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 keyJWT
- 备选(慎用/止血):通过 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. 症状PGRST301JWT 无法解码)导致访问异常
**现象**
- 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 keyrest 解不开”的典型问题。
如果这两个 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` 匹配并同步更新Kongkey-auth+ 你的后端配置(例如 `server/config.json`)。
可以使用本仓库脚本从 `JWT_SECRET` 生成匹配的 keyHS256
```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;
```

View 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`)后自动补偿发送。

View File

@@ -0,0 +1,460 @@
# 物流 Webhook 接收器 & Push Server推送后台总览
面向读者:需要理解“物流事件如何入库、如何触发消息、如何推送到 App”的后端/运维/联调同学。
本文只做**总结性说明**;细节请跳转到对应子文档与源码。
---
## 1. 这两套后台分别解决什么问题?
### Webhook 接收器webhook-receiver
**一句话**:把第三方/承运方推过来的“物流轨迹事件”接进来,完成验签(可选)、留痕,并把事件写入 Supabase 的 `platform_express_*` 表。
它关注的是:
- Webhook 请求能否稳定接收(可公网暴露、可控验签)
- 原始 payload 是否可追溯(用于审计/排障)
- 轨迹事件是否能归一化写入(供业务查询、消息生成)
- 运单摘要状态是否能更新(用于页面“当前状态”展示)
### Push Serverpush-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 Serverpush-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` 开了 RLSPostgREST 用 `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` 可能包含敏感信息,生产上建议严格控制读取权限并记录审计。

View File

@@ -0,0 +1,80 @@
**部署工作流(打包 → 上传/发布 → smoketest**
- **目的**: 描述如何把本地 `uniCloud` 云函数项目自动化打包、上传并发布为可供后端调用的云函数;说明两种上传方式(直接调用上传 API / 使用 `unicloud` CLI与 smoketest 验证流程;给出 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 的环境)
- 要求:提供上传 URLCLOUD_UPLOAD_URL 或 `--upload`)、必要的 tokenCLOUD_UPLOAD_TOKEN和可能的额外表单字段例如 appId
- 流程:脚本把 zip 作为 `multipart/form-data` POST 到上传端点;若上传端点返回仅是“已接收”而非“已发布”,还需调用发布/部署 APICLOUD_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) smoketest发布后验证
- 在上传/发布成功后,应通过函数的 invoke URL 做一次简单调用,验证函数行为(例如发送测试推送或返回 expected response。脚本支持传入 `--invokeUrl` / `--funcInvokeUrl` 与 `--testCid` 做自动验证。
- 若 smoketest 失败:检查是否为 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用来做 smoketest
- TEST_DEVICE_CID / --testCid : 用于 push smoketest 的测试设备 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