diff --git a/.github/workflows/deploy-cloudfunc.yml b/.github/workflows/deploy-cloudfunc.yml new file mode 100644 index 00000000..941be7d4 --- /dev/null +++ b/.github/workflows/deploy-cloudfunc.yml @@ -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" diff --git a/.hbuilderx/launch.json b/.hbuilderx/launch.json index 8ae452a8..6466c1cd 100644 --- a/.hbuilderx/launch.json +++ b/.hbuilderx/launch.json @@ -4,6 +4,19 @@ { "playground" : "standard", "type" : "uni-app:app-android" + }, + { + "app" : { + "launchtype" : "remote" + }, + "default" : { + "launchtype" : "local" + }, + "h5" : { + "launchtype" : "local" + }, + "provider" : "alipay", + "type" : "uniCloud" } ] } diff --git a/BACKEND_MIGRATION_PLAN.md b/BACKEND_MIGRATION_PLAN.md new file mode 100644 index 00000000..82409a1d --- /dev/null +++ b/BACKEND_MIGRATION_PLAN.md @@ -0,0 +1,172 @@ +# 后端收敛与防护设计(最小可落地方案) + +说明:本文档针对当前仓库中前端直接写 Supabase(存在风险:前端使用 service_role key)问题,提供可执行的最小后台设计、接口与 DB 改造清单,供开发/运维快速落地。 + +--- + +## 目标(Why) +- 阻断前端持有 `service_role` 导致的越权写库风险。 +- 将关键业务写入(接单/订单状态/资金/库存等)收敛到可信后端,保证原子性、幂等和审计。 +- 在数据库侧加入最后一道防线(约束 + RLS + RPC)。 + +--- + +## 概要执行步骤(What / High level) +1. 立刻移除前端的 `service_role`,前端只用 `anon`(阻断最大风险)。 +2. 实现最小后台 API(订单管理、核心状态流转 rpc + HTTP 接口)。 +3. 在数据库添加约束、RLS 策略与 RPC(把关键状态流转做成原子函数)。 +4. 前端分阶段切换到新 API 并回归测试。 +5. 补充审计、幂等与监控,逐步迁移其它敏感写入。 + +--- + +## 详细步骤(可直接执行) + +### A. 紧急措施(立即) +- 编辑 `ak/config.uts`:注释或删除明文 `SUPA_KEY`(service_role),替为 anon 或从构建环境注入。 +- 确认前端不再把 service_role 打包发布(CI/构建流水线更新)。 + +### B. 最小后端 API(优先交付) +在 `server/` 下新增 `server/routes/delivery.js`(或 Fastify 插件)。 + +推荐接口(统一返回 `{ ok, code?, message?, data? }`): +- POST /api/v1/delivery/accept-task + - body: { task_id, action_id?: string } + - auth: Bearer user_jwt + - 后端流程:验证 token → 验证司机权限 → 调用 RPC `rpc_accept_task(uid, task_id, action_id)` → 返回 task 新状态 +- POST /api/v1/delivery/update-status + - body: { task_id, new_status, action_id, metadata? } + - 后端流程:验证 → 调用 `rpc_update_delivery_status(...)`(RPC 内部做多表事务) +- POST /api/v1/notifications/express/create + - body: notification payload + message_id/action_id(幂等) + - 后端流程:幂等检查 -> 插入 -> 触发 push(内部队列) + +后端实现要点: +- 鉴权:解析并验证前端 Supabase JWT,取 `uid` 作为操作人。 +- 内部调用:后端使用环境变量 `SERVICE_ROLE_KEY` 或直接调用 RPC(推荐)执行受权写入。 +- 返回格式统一并带错误码,便于前端处理与监控。 + +### C. Postgres RPC(示例 SQL) +下面为 `rpc_accept_task` 草稿,供 DB 管理员在 Dev 环境调整与测试: + +```sql +-- rpc_accept_task: driver 领取任务(幂等 + 原子) +CREATE OR REPLACE FUNCTION public.rpc_accept_task(p_driver_uuid uuid, p_task_uuid uuid, p_action_id text) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_task RECORD; +BEGIN + -- 幂等:插入到去重表,若已存在则返回已处理结果 + INSERT INTO action_dedupe(action_id, created_at) + VALUES (p_action_id, now()) + ON CONFLICT (action_id) DO NOTHING; + + -- 执行原子更新 + WITH u AS ( + UPDATE ml_orders + SET status = 2, updated_at = now() + WHERE id = p_task_uuid AND status = 1 + RETURNING * + ) + SELECT * INTO v_task FROM u LIMIT 1; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'message', 'task not available'); + END IF; + + -- 写审计 + INSERT INTO audit_logs(actor_id, action, target_table, target_id, payload, created_at) + VALUES (p_driver_uuid, 'accept_task', 'ml_orders', p_task_uuid, row_to_json(v_task), now()); + + RETURN jsonb_build_object('ok', true, 'task', to_jsonb(v_task)); +END; +$$; +``` + +注意: +- 使用 `SECURITY DEFINER` 并确保函数拥有适当权限(仅 server/service_role 可调用)。 +- `action_dedupe` 表需创建并设 unique(action_id)。 + +### D. RLS 与 约束(示例) +- 开启 RLS,例如对 `user_addresses`: +```sql +ALTER TABLE user_addresses ENABLE ROW LEVEL SECURITY; +CREATE POLICY user_owns_address ON user_addresses + FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` +- 对关键表(如 `ml_orders`): + - 禁止直接由匿名或普通前端更新关键列;只允许 `rpc` / service 调用通过函数更新。 +- 索引/约束: + - message_id 唯一:`CREATE UNIQUE INDEX ux_express_notifications_message_id ON express_notifications(message_id);` + - 补全 NOT NULL / FK / CHECK(枚举字段限制等)。 + +### E. 鉴权实现要点(后端) +- 验证前端 JWT:解析/校验 Supabase JWT(可使用 Supabase 的用户 API 或直接 JWT 验证)。从 token 获取 `uid` 作为操作人。 +- 后端自身使用环境变量 `SERVICE_ROLE_KEY` 调用 Supabase Admin API 或直接调用 Postgres RPC(建议后者更原子)。 +- 不要在后端把 `service_role` 返回或写进前端文件。 + +### F. 幂等与去重 +- 对所有会被重复调用的外部动作(webhook、client retry)要求 `action_id`/`message_id`,并在 DB 层做 `ON CONFLICT DO NOTHING` 或在 RPC 先检查 `action_dedupe`。 + +### G. 审计与监控 +- 新建 `audit_logs(actor_id, action, target_table, target_id, payload, created_at)` 并在 RPC 中写入。 +- 错误报警(Sentry 或日志轮询),并在关键接口记录 metrics(错误率、latency、冲突数)。 + +--- + +## 迁移与上线路线(分阶段) +1. Dev:实现 RPC + API,更新 dev 配置把前端用 anon,完成单元测试与集成测试。 +2. Stage:灰度发布后端 API,部分司机/少量流量切换到 API。监控 48h。 +3. Prod:全面切换,删除前端中的直写调用(或通过 feature-flag 关闭)。 +4. 回滚:保留旧直写代码并用 feature flag 随时回退;若回退需同时短期恢复前端原 key(仅极端应急,注意风险并在短时间内移除)。 + +--- + +## 最小交付清单(可追踪任务) +- [ ] 注释/移除前端 `service_role`:`ak/config.uts`(紧急) +- [ ] 在 `server/` 新增 `POST /api/v1/delivery/accept-task`(鉴权 + 调用 RPC) +- [ ] 在 DB 创建 `rpc_accept_task`、`action_dedupe`、`audit_logs` 表 +- [ ] 对 `user_addresses`、`ml_orders` 等启用 RLS 策略(逐表) +- [ ] 前端将接单/确认调用迁移到新 API(feature-flag)并回归测试 +- [ ] 部署后监控并逐步扩大灰度 + +--- + +## 风险与注意事项 +- 风险:短期内若不移除 `service_role`,即便实现后台 API,也无法阻止攻击者通过已有 key 直接写库。优先级最高。 +- 注意隐私/合规:审计日志要避免写入敏感明文(例如完整支付凭证)或加密存储。 +- DB 权限:确保 RPC 使用受限角色执行并限制 RPC 所能做的动作。 + +--- + +## 推荐下一步(我可以马上帮你做) +- 选项 1:生成并提交 `ak/config.uts` 的 patch,将 `SUPA_KEY` 替换为示例 anon(阻断风险)。 +- 选项 2:生成 `rpc_accept_task` 的完整 SQL + `server/routes/delivery.js` 的 Node 实现样例(包含鉴权中间件与调用示例)。 +- 选项 3:列出仓库内所有前端写入点并按“必须走后端 / 可保留直写”分级清单(便于逐步迁移)。 + +请回复 `1`、`2` 或 `3` 选项,或告诉我需要调整的文档内容/格式。 + +--- +## 附录:前端不得直接连接数据库(简明实施指南) + +结论:所有需要提升权限、跨表原子性、审计或幂等保证的写入必须走可信后端;仅在严格受限(RLS + 约束)且只影响用户自身资源时,前端可用 anon key 直写。 + +- 何时必须走后端:更新 `driver_id`、订单状态、资金/结算、库存变更、跨表事务、需要审计或幂等的操作、需使用 `service_role` 权限的写入。 +- 何时可允许前端直写:仅用户自身数据(例如 `user_addresses`)且已启用 RLS 与完整约束,且不涉及跨表或审计要求。 + +最小后端职责(示例): +- 验证前端 JWT(取 `uid`);做权限校验与幂等检查(`action_id`)。 +- 调用 Postgres RPC(`SECURITY DEFINER`)或使用 `SERVICE_ROLE_KEY` 完成受权写入。 +- 写入 `audit_logs` 并返回统一错误格式 `{ ok, code?, message?, data? }`。 + +紧急建议(复述以便执行): +- 立即移除或注释前端的 `service_role`(已在工作区修改 `ak/config.uts`)。 +- 在 CI 中阻止带有 `service_role` 的 key 进入前端配置或打包产物。 +- 为关键流实现 RPC(如 `rpc_accept_task`)并暴露最小后端 API(如 `/api/v1/delivery/accept-task`),逐步将前端写入迁移到后端。 + +需要我把这个附录再整理为单独文件或把 `rpc_accept_task` SQL 与 `server/routes/delivery.js` 示例直接追加到本文件吗? diff --git a/PAGES_ROUTES.md b/PAGES_ROUTES.md index 14c36d31..6faf5217 100644 --- a/PAGES_ROUTES.md +++ b/PAGES_ROUTES.md @@ -45,7 +45,7 @@ Generated from root pages.json (pages + subPackages). - pages/mall/consumer/subscription/subscribe-checkout - pages/mall/consumer/subscription/my-subscriptions -### pages/mall/delivery + ### pages/mall/analytics - pages/mall/analytics/index diff --git a/README.md b/README.md index a069a17d..82f78526 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛍️ Mall (uni-app / uvue) -本仓库为 uni-app(uvue/uts)商城项目,包含消费者端、配送端、数据分析、管理后台、商家端、客服端等模块。 +本仓库为 uni-app(uvue/uts)商城项目,包含消费者端、数据分析、管理后台、商家端、客服端等模块(注:旧有独立配送端已弃用,现改为第三方物流对接)。 ## 📦 路由与分包 @@ -18,7 +18,6 @@ pages/ user/ # 登录/注册/用户中心等公共页面 mall/ consumer/ # 消费者端 - delivery/ # 配送端 analytics/ # 数据分析 admin/ # 管理后台 merchant/ # 商家端 @@ -54,3 +53,12 @@ ak/ - 请避免提交本地环境相关配置(如 `ak/config.uts`)到仓库(已通过 `.gitignore` 处理) - 分包页面路径变更请同步更新 `pages.json` + +## 📣 配送模块 — 推送与设备(整合说明) + +项目中 `pages/mall/delivery/doc/需求文档` 目录包含多份关于配送、轨迹与推送的需求与建表示例。为了便于 DBA/后端/前端对齐,已将核心要点整合为草稿: + +- 推送建表迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql` +- 推送完整附录(含平台侧建表示例):`pages/mall/delivery/doc/需求文档/推送与设备需求文档_含建表附录.md` + +建议下一步:在测试库执行迁移并验证 `notify_queue` / 后台消费者流程。若需要,我可以生成迁移运行命令或提供后台消费者示例代码。 diff --git a/ak/config.uts b/ak/config.uts index 690cbaab..b72a2a2f 100644 --- a/ak/config.uts +++ b/ak/config.uts @@ -6,15 +6,18 @@ //自己的配置自己解开即可 //export const SUPA_URL: string = 'http://192.168.1.61:18000' //export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' -//export const SUPA_URL: string = 'http://192.168.1.62:18000' -//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' -export const SUPA_URL: string = 'http://192.168.1.63:18000' -export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU' +export const SUPA_URL: string = 'http://192.168.1.62:18000' +export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzczNjIxMTQzLCJleHAiOjE5MzEzMDExNDN9.gkYe875_vsdcdsKbhOTwwe2klkuMNj_UY45aq4zwuy0' +// export const SUPA_URL: string = 'http://192.168.1.63:18000' +// export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU' // 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.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.62:18000/realtime/v1/websocket' +// export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket' + +// 推送服务地址(用于本地调试,可改为 http://:7301) +export const PUSH_SERVER_URL: string = 'http://192.168.1.62:7301' // 备用配置(已注释,如需切换可取消注释) // 开发环境 - 其他内网地址 diff --git a/manifest.json b/manifest.json index 98b47747..d2310c6b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,74 +1,92 @@ { - "name": "mall", - "appid": "__UNI__YOUR_APP_ID__", - "description": "A multi-role e-commerce application.", - "versionName": "1.0.0", - "versionCode": "100", - "transformPx": false, - "app-plus": { - "usingComponents": true, - "nvueStyleCompiler": "uni-app", - "compilerVersion": 3, - "splashscreen": { - "alwaysShowBeforeRender": true, - "waiting": true, - "autoclose": true, - "delay": 0 - }, - "modules": {}, - "distribute": { - "android": { - "permissions": [ - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ] - }, - "ios": {}, - "sdkConfigs": {} - } - }, - "quickapp": {}, - "mp-weixin": { - "appid": "", - "setting": { - "urlCheck": false - }, - "usingComponents": true - }, - "mp-alipay": { - "usingComponents": true - }, - "mp-baidu": { - "usingComponents": true - }, - "mp-toutiao": { - "usingComponents": true - }, - "uniStatistics": { - "enable": false - }, - "vueVersion": "3", - "uni-app-x": {}, - "h5": { - "title": "mall", - "router": { - "mode": "hash", - "base": "./" - } - } + "name": "mall", + "appid": "__UNI__9462CA7", + "description": "A multi-role e-commerce application.", + "versionName": "1.0.0", + "versionCode": "100", + "transformPx": false, + "app-plus": { + "usingComponents": true, + "nvueStyleCompiler": "uni-app", + "compilerVersion": 3, + "splashscreen": { + "alwaysShowBeforeRender": true, + "waiting": true, + "autoclose": true, + "delay": 0 + }, + "modules": {}, + "distribute": { + "android": { + "permissions": [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "ios": {}, + "sdkConfigs": {} + } + }, + "quickapp": {}, + "mp-weixin": { + "appid": "", + "setting": { + "urlCheck": false + }, + "usingComponents": true, + "unipush": { + "enable": false + } + }, + "mp-alipay": { + "usingComponents": true + }, + "mp-baidu": { + "usingComponents": true + }, + "mp-toutiao": { + "usingComponents": true + }, + "uniStatistics": { + "enable": false + }, + "vueVersion": "3", + "uni-app-x": {}, + "h5": { + "title": "mall", + "router": { + "mode": "hash", + "base": "./" + } + }, + "web": { + "router": { + "mode": "" + }, + "unipush": { + "enable": false + } + }, + "app-android": { + "distribute": { + "modules": { + "uni-push": {} + } + } + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6f522917..5cc94ceb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "mall", "dependencies": { - "echarts": "^6.0.0" + "@supabase/supabase-js": "^2.99.1", + "echarts": "^6.0.0", + "express": "^5.2.1" }, "devDependencies": { "@dcloudio/types": "^3.4.29", @@ -22,7 +24,7 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", @@ -41,7 +43,7 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", @@ -51,7 +53,7 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", @@ -75,7 +77,7 @@ }, "node_modules/@eslint/js": { "version": "8.57.1", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", @@ -85,7 +87,7 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, @@ -101,7 +103,7 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", @@ -115,7 +117,7 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, @@ -123,7 +125,7 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", @@ -137,7 +139,7 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", @@ -147,7 +149,7 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", @@ -159,17 +161,164 @@ "node": ">= 8" } }, + "node_modules/@supabase/auth-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.1.tgz", + "integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/auth-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.1.tgz", + "integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz", + "integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.1.tgz", + "integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.1.tgz", + "integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.1.tgz", + "integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.1", + "@supabase/functions-js": "2.99.1", + "@supabase/postgrest-js": "2.99.1", + "@supabase/realtime-js": "2.99.1", + "@supabase/storage-js": "2.99.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -181,7 +330,7 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", @@ -190,9 +339,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -208,7 +357,7 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", @@ -218,7 +367,7 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", @@ -234,28 +383,52 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", @@ -264,9 +437,47 @@ "concat-map": "0.0.1" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", @@ -276,7 +487,7 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", @@ -293,7 +504,7 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", @@ -306,21 +517,61 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", @@ -335,7 +586,7 @@ }, "node_modules/cssesc": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", @@ -348,9 +599,8 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -366,14 +616,23 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/doctrine": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", @@ -384,6 +643,20 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/echarts": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", @@ -394,9 +667,60 @@ "zrender": "6.0.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", @@ -409,7 +733,7 @@ }, "node_modules/eslint": { "version": "8.57.1", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, @@ -466,7 +790,7 @@ }, "node_modules/eslint-plugin-vue": { "version": "9.33.0", - "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", "dev": true, "license": "MIT", @@ -489,7 +813,7 @@ }, "node_modules/eslint-scope": { "version": "7.2.2", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", @@ -506,7 +830,7 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", @@ -519,7 +843,7 @@ }, "node_modules/espree": { "version": "9.6.1", - "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", @@ -537,7 +861,7 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", @@ -550,7 +874,7 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", @@ -563,7 +887,7 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", @@ -573,7 +897,7 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", @@ -581,30 +905,82 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", - "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", @@ -614,7 +990,7 @@ }, "node_modules/file-entry-cache": { "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", @@ -625,9 +1001,30 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", @@ -644,7 +1041,7 @@ }, "node_modules/flat-cache": { "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", @@ -658,24 +1055,88 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -695,7 +1156,7 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", @@ -708,7 +1169,7 @@ }, "node_modules/globals": { "version": "13.24.0", - "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", @@ -722,16 +1183,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", @@ -739,9 +1212,78 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", @@ -751,7 +1293,7 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", @@ -768,7 +1310,7 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", @@ -778,7 +1320,7 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, @@ -790,14 +1332,22 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", @@ -807,7 +1357,7 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", @@ -820,7 +1370,7 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", @@ -828,16 +1378,22 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", @@ -850,28 +1406,28 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", @@ -881,7 +1437,7 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", @@ -895,7 +1451,7 @@ }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", @@ -911,22 +1467,77 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -938,21 +1549,29 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nth-check": { "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, "license": "BSD-2-Clause", @@ -963,11 +1582,34 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -975,7 +1617,7 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", @@ -993,7 +1635,7 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", @@ -1009,7 +1651,7 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", @@ -1025,7 +1667,7 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", @@ -1036,9 +1678,18 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", @@ -1048,7 +1699,7 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", @@ -1058,7 +1709,7 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", @@ -1066,9 +1717,19 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/postcss-selector-parser": { "version": "6.1.2", - "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", @@ -1082,7 +1743,7 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", @@ -1090,9 +1751,22 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", @@ -1100,9 +1774,24 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ @@ -1121,9 +1810,33 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", @@ -1133,7 +1846,7 @@ }, "node_modules/reusify": { "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", @@ -1144,7 +1857,7 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, @@ -1159,9 +1872,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ @@ -1183,9 +1912,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", @@ -1196,9 +1931,60 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", @@ -1211,7 +1997,7 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", @@ -1219,9 +2005,90 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", @@ -1234,7 +2101,7 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", @@ -1247,7 +2114,7 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", @@ -1260,11 +2127,20 @@ }, "node_modules/text-table": { "version": "0.2.0", - "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -1272,7 +2148,7 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", @@ -1285,7 +2161,7 @@ }, "node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", @@ -1296,9 +2172,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", @@ -1308,14 +2213,23 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", - "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", "dev": true, "license": "MIT", @@ -1340,7 +2254,7 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", @@ -1356,7 +2270,7 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", @@ -1366,14 +2280,34 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", "dev": true, "license": "Apache-2.0", @@ -1383,7 +2317,7 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index e644a979..c16f492f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "lint:fix": "eslint --ext .js,.vue,.uvue --fix pages layouts" }, "dependencies": { - "echarts": "^6.0.0" + "@supabase/supabase-js": "^2.99.1", + "echarts": "^6.0.0", + "express": "^5.2.1" }, "devDependencies": { "@dcloudio/types": "^3.4.29", diff --git a/pages.json b/pages.json index c74049ac..170a696f 100644 --- a/pages.json +++ b/pages.json @@ -1,12 +1,5 @@ { "pages": [ - { - "path": "pages/mall/admin/homePage/index", - "style": { - "navigationBarTitleText": "管理后台", - "navigationStyle": "custom" - } - }, { "path": "pages/user/login", "style": { @@ -275,6 +268,13 @@ "navigationStyle": "custom" } }, + { + "path": "all", + "style": { + "navigationBarTitleText": "待接取任务", + "navigationStyle": "custom" + } + }, { "path": "earnings", "style": { @@ -365,6 +365,69 @@ "navigationBarTitleText": "设置", "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" + } } ] }, diff --git a/pages/mall/admin/docs/CRMEB_ROUTE_FIX_LOG.md b/pages/mall/admin/docs/CRMEB_ROUTE_FIX_LOG.md index bc80a9b7..d9bd9813 100644 --- a/pages/mall/admin/docs/CRMEB_ROUTE_FIX_LOG.md +++ b/pages/mall/admin/docs/CRMEB_ROUTE_FIX_LOG.md @@ -245,3 +245,23 @@ uni-app-x 项目结构 - **数据管理**: 支持移除已选优惠券标签。 - **路由映射**: 已在 `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` 模拟账号的持久化支持。 + diff --git a/pages/mall/analytics/profile.uvue b/pages/mall/analytics/profile.uvue index bf313fe5..3b4c99d4 100644 --- a/pages/mall/analytics/profile.uvue +++ b/pages/mall/analytics/profile.uvue @@ -1014,7 +1014,6 @@ function goToFeedback() { color: #8c929b; } - .trend-legend { display: flex; flex-direction: row !important; diff --git a/pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql b/pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql index 81a3096a..4710e1e2 100644 --- a/pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql +++ b/pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql @@ -7,6 +7,8 @@ -- - 完成配送:ml_delivery_tasks.status = 5 -- - 配送时长:delivered_at - assigned_at(分钟) -- - avg_fee:delivery_fee 平均 + +-- 弃用 -- ===================================================================================== -- 1) 配送效率日趋势 diff --git a/pages/mall/delivery/all.uvue b/pages/mall/delivery/all.uvue new file mode 100644 index 00000000..bbdb9b58 --- /dev/null +++ b/pages/mall/delivery/all.uvue @@ -0,0 +1,360 @@ + + + + + diff --git a/pages/mall/delivery/delivery.md b/pages/mall/delivery/delivery.md deleted file mode 100644 index 0f8863a2..00000000 --- a/pages/mall/delivery/delivery.md +++ /dev/null @@ -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` 模块是一个完整的配送员工作系统,涵盖了: - -- **个人管理**(资料、设置) -- **任务处理**(接单、配送、完成) -- **数据统计**(收入、评价、历史) -- **车辆管理** - -结构清晰、功能完整,适合用于实际配送平台开发。 - ---- diff --git a/pages/mall/delivery/doc/old(弃用)/DELIVERY_LOGIC.md b/pages/mall/delivery/doc/old(弃用)/DELIVERY_LOGIC.md new file mode 100644 index 00000000..619d390e --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/DELIVERY_LOGIC.md @@ -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 并实现。 \ No newline at end of file diff --git a/pages/mall/delivery/doc/old(弃用)/FIX_REPORT_20260203.md b/pages/mall/delivery/doc/old(弃用)/FIX_REPORT_20260203.md new file mode 100644 index 00000000..f9581d81 --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/FIX_REPORT_20260203.md @@ -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` 是否符合筛选条件。 diff --git a/pages/mall/delivery/doc/old(弃用)/STATE_1-5_LOGIC.md b/pages/mall/delivery/doc/old(弃用)/STATE_1-5_LOGIC.md new file mode 100644 index 00000000..69b5015c --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/STATE_1-5_LOGIC.md @@ -0,0 +1,81 @@ +# 状态 1–5 行为与代码位置说明 + +本文档针对 `ml_delivery_tasks.status` 中 1–5 的状态,逐项列出在 delivery 页面中涉及的展示、查询、更新位置、按钮/动作与已发现的不一致点与建议。 + +## 状态 1 — 待接取 +- 映射文本/样式 + - `index.uvue`: `getTaskStatusClass` -> `task-pending`(见 index.uvue getTaskStatusClass) + - `index.uvue`: `getTaskStatusText` case 1 -> '待接取'(index.uvue#L571) + - `order-history.uvue`: 同样映射(order-history.uvue#L230, #L242) +- 查询/展示 + - `loadAvailableOrders` 查询:`supa.from('ml_delivery_tasks').is('driver_id','null').eq('status', 1)`(index.uvue loadAvailableOrders) + - 在可接订单列表与当前任务区显示“接受任务/接受订单”按钮(index.uvue#L81、order card accept button) +- 更新/操作 + - 接单时更新为 `status = 2`:`supa.from('ml_delivery_tasks').update({ driver_id: driverId, status: 2 }).eq('id', ...)`(index.uvue acceptTask / acceptOrder,index.uvue#L602,#L756) +- 建议/注意 + - 接单使用条件更新或后端 RPC 避免抢单竞态(目前无 WHERE driver_id IS NULL 条件)。 + +## 状态 2 — 已接取 +- 映射文本/样式 + - `index.uvue` 文本 '已接取'(index.uvue#L572),样式 `task-accepted`(index.uvue#L560) + - `order-history.uvue` 相应映射(order-history.uvue#L243) +- 查询/展示 + - 当前任务区显示“开始取货”按钮(index.uvue#L82) + - `order-detail.uvue` 若 `order.status === 2` 显示“前往取货/正在取货”(order-detail.uvue#L169-L170) +- 更新/操作 + - `startPickup()` 会将 task 状态更新为 3(index.uvue startPickup) + - 接单后代码尝试同步 `ml_orders.order_status = 2`(index.uvue#L766) +- 建议/注意 + - 同步 ml_orders 的操作应做失败回退处理或在服务端做原子事务。 + +## 状态 3 — 取货中 +- 映射文本/样式 + - `index.uvue` 文本 '取货中'(index.uvue#L573),样式 `task-picking`(index.uvue#L561) + - `order-history.uvue` 同步映射(order-history.uvue#L244) +- 查询/展示 + - 当前任务区显示“确认取货”按钮(index.uvue#L83) + - `order-detail.uvue` 在 route 区显示“确认取货”按钮(order-detail.uvue#L55,#L173) +- 更新/操作 + - `confirmPickup()` 将 status -> 4 并写入 `pickup_time`,同时尝试把 `ml_orders.order_status = 4`(index.uvue & order-detail) +- 建议/注意 + - 确认取货应当原子更新任务与订单状态,或由后端封装接口完成。 + +## 状态 4 — 已取货 +- 映射文本/样式 + - `index.uvue` 文本 '已取货'(index.uvue#L574),样式 `task-picked`(index.uvue#L562) + - `order-history.uvue` 文本 '已取货'(order-history.uvue#L245) +- 查询/展示 + - 当前任务区显示“开始配送”按钮(index.uvue#L84) + - `order-detail.uvue` 显示“确认送达”按钮(order-detail.uvue#L68,#L176) +- 更新/操作 + - `startDelivery()` 将 status -> 5(index.uvue startDelivery) +- 建议/注意 + - 明确 4 与 5 的边界并在统计/历史中统一口径(是否将 5 视作已完成)。 + +## 状态 5 — 配送中 +- 映射文本/样式 + - `index.uvue` 文本 '配送中'(index.uvue#L575),样式 `task-delivering`(index.uvue#L563) + - `order-history.uvue` 在样式处标为 `status-delivered`(order-history.uvue#L234)且文本处 `case 5: return '已完成'`(order-history.uvue#L246)——存在不一致。 +- 查询/展示 + - 当前任务区显示“确认送达”入口(index.uvue#L85 -> showConfirmDeliveryDialog) + - `order-detail.uvue` 在状态 >=5 时进度条显示为已送达(getStatusDesc) +- 更新/操作 + - `confirmDelivery()` 将 task.status -> 6 并写入 `delivered_time`,再尝试同步 `ml_orders.order_status = 5`(index.uvue#L679-691) +- 建议/注意 + - 目前统计(如 todayStats)将 `status >= 5` 视作“已完成”计数,而 UI 有时把 5 显示为“配送中”,建议统一规则(推荐:把 6 视作真正完成,5 为配送中)。 + +## 跨页不一致与改进建议(摘要) +- 不一致点:`status = 5` 在不同页面既被视作“配送中”,又被视作“已完成”;`confirmDelivery` 设置 task=6,但统计使用 `>=5` 判断完成,导致计数或展示差异。 +- 建议: + 1. 明确“完成”阈值(推荐:status === 6 为已完成,status === 5 为配送中),并在所有统计、过滤、UI 映射处统一实现。 + 2. 将状态映射抽到共享工具文件 `utils/deliveryStatus.uts` 并替换硬编码。 + 3. 接单、确认取货、确认送达等关键操作应使用后端事务或条件更新避免竞态与不一致。 + +--- + +文档已生成于 `pages/mall/delivery/STATE_1-5_LOGIC.md`。要我现在: +- A) 将 `getTaskStatusText` / `getOrderStatusText` 抽到 `utils/deliveryStatus.uts` 并替换页面中的映射,或 +- B) 实现接单的条件更新补丁(修改 `acceptTask` / `acceptOrder`),或 +- C) 仅把“5 是否计作完成”的规范改写回 `DELIVERY_LOGIC.md` 并提交。 + +请选择一项继续。 \ No newline at end of file diff --git a/pages/mall/delivery/doc/old(弃用)/all.md b/pages/mall/delivery/doc/old(弃用)/all.md new file mode 100644 index 00000000..fcf50ed0 --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/all.md @@ -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 实现秒级抢单逻辑与状态同步。 diff --git a/pages/mall/delivery/db/create_pending_orders.sql b/pages/mall/delivery/doc/old(弃用)/db/create_pending_orders.sql similarity index 100% rename from pages/mall/delivery/db/create_pending_orders.sql rename to pages/mall/delivery/doc/old(弃用)/db/create_pending_orders.sql diff --git a/pages/mall/delivery/db/create_test_orders.sql b/pages/mall/delivery/doc/old(弃用)/db/create_test_orders.sql similarity index 100% rename from pages/mall/delivery/db/create_test_orders.sql rename to pages/mall/delivery/doc/old(弃用)/db/create_test_orders.sql diff --git a/pages/mall/delivery/doc/db-data-generation.md b/pages/mall/delivery/doc/old(弃用)/db/db-data-generation.md similarity index 100% rename from pages/mall/delivery/doc/db-data-generation.md rename to pages/mall/delivery/doc/old(弃用)/db/db-data-generation.md diff --git a/pages/mall/delivery/db/realistic_mock_data.sql b/pages/mall/delivery/doc/old(弃用)/db/realistic_mock_data.sql similarity index 100% rename from pages/mall/delivery/db/realistic_mock_data.sql rename to pages/mall/delivery/doc/old(弃用)/db/realistic_mock_data.sql diff --git a/pages/mall/delivery/doc/delivery.md b/pages/mall/delivery/doc/old(弃用)/delivery.md similarity index 100% rename from pages/mall/delivery/doc/delivery.md rename to pages/mall/delivery/doc/old(弃用)/delivery.md diff --git a/pages/mall/delivery/doc/earnings.md b/pages/mall/delivery/doc/old(弃用)/earnings.md similarity index 87% rename from pages/mall/delivery/doc/earnings.md rename to pages/mall/delivery/doc/old(弃用)/earnings.md index c8b8a825..147f2b3b 100644 --- a/pages/mall/delivery/doc/earnings.md +++ b/pages/mall/delivery/doc/old(弃用)/earnings.md @@ -58,6 +58,8 @@ const result = Array.from(orderMap.values()) ## 数据源更新:包含已接订单(Accepted) - 需求说明:为了与“历史订单”页面保持一致,收入统计应包含配送员已接取(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 查询示例(后端或前端按需实现): ``` @@ -78,6 +80,7 @@ LIMIT :size OFFSET :offset; ```js 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) +// 对于 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() ``` diff --git a/pages/mall/delivery/doc/index.md b/pages/mall/delivery/doc/old(弃用)/index.md similarity index 72% rename from pages/mall/delivery/doc/index.md rename to pages/mall/delivery/doc/old(弃用)/index.md index dbea39f3..b3ef5702 100644 --- a/pages/mall/delivery/doc/index.md +++ b/pages/mall/delivery/doc/old(弃用)/index.md @@ -46,6 +46,16 @@ await supa.from('ml_delivery_tasks') - `toggleWorkStatus()`:切换 `isOnline` 并调用 `startWork()` / `stopWork()`。上线时会刷新可接订单列表。 - 接单/开始取货/确认取货/开始配送/确认送达等均通过对 `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 事务)以防止竞单冲突。 - `loadAvailableOrders()` 最好按司机服务区域与距离筛选,并使用分页/实时推送代替频繁轮询。 diff --git a/pages/mall/delivery/doc/more-orders.md b/pages/mall/delivery/doc/old(弃用)/more-orders.md similarity index 100% rename from pages/mall/delivery/doc/more-orders.md rename to pages/mall/delivery/doc/old(弃用)/more-orders.md diff --git a/pages/mall/delivery/doc/old(弃用)/order-detail.md b/pages/mall/delivery/doc/old(弃用)/order-detail.md new file mode 100644 index 00000000..6b683279 --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/order-detail.md @@ -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'] +``` + diff --git a/pages/mall/delivery/doc/old(弃用)/order-history.md b/pages/mall/delivery/doc/old(弃用)/order-history.md new file mode 100644 index 00000000..9095fd79 --- /dev/null +++ b/pages/mall/delivery/doc/old(弃用)/order-history.md @@ -0,0 +1,106 @@ +# 历史订单 页面说明(order-history.uvue) + +## 概要 +`order-history.uvue` 用于配送员查看历史订单与近期任务。页面会展示: +- 以 `ml_delivery_tasks` 为配送端“状态真源”的任务记录(只要 `driver_id = 当前司机` 且 `status >= 2`,均会包含在统计/列表中); +- 页面会批量回填对应 `ml_orders.order_no` 以补全显示(若 `order_no` 缺失会显示回退文本),避免直接以 `ml_orders.order_status` 作为展示依据而导致与配送端不一致。 + +页面关键点: +- 首次加载时通过 `loadOrderHistory()` 拉取数据;页面每次显示时会检查本地存储 `completed_order_for_history`,并把刚完成订单插入列表表头。 +- 使用 Supabase 客户端 `supa` 读取 `ml_delivery_tasks` 与 `ml_orders` 表,并通过 `getCurrentUser()` / `getCurrentUserId()` 获取当前用户/司机 id。 + +## 行为细节 +- 当前实现优先以 `ml_delivery_tasks`(status >= 2)作为数据源,页面会: + - 查询 `ml_delivery_tasks` 中与 `driver_id` 相关的任务,按时间排序并映射为页面项; + - 对获取到的 `order_id` 列表做一次批量查询 `ml_orders` 以回填 `order_no` 和订单详情; + - 对没有匹配到 `ml_orders` 的 `order_id`,页面会用短 id 回退显示并在控制台打印缺失 id 列表,便于后台核查数据不一致的原因。 +- 为避免重复展示,页面在将“当前任务对应订单”插入顶部时,会先检查 `orderList` 是否已有相同 `id`。 + +## 依赖 & 相关文件 +- 页面文件:`pages/mall/delivery/order-history.uvue`(当前) +- Supabase 实例:`components/supadb/aksupainstance.uts`(导出 `supa` 与 `supaReady`) +- 用户/会话工具:`utils/store.uts`(`getCurrentUser()`、`getCurrentUserId()`) +- 相关文档: + - `pages/mall/delivery/doc/earnings.md`(收入聚合与 DB 建议) + - `pages/mall/delivery/doc/test-user-1_at_123.com.md`(测试用户与 SQL 示例) + +## 已实现的防护与诊断信息 +- `supaReady` 在会话恢复时可能会进行网络刷新(refreshSession),该步骤可能较慢。为避免页面长时间阻塞,页面中对 `supaReady` 使用了 `Promise.race` 的 1.5s 超时包装:如果超时会打印警告并继续执行(某些依赖用户 id 的查询可能因此为空)。 +- 如果 `getCurrentUserId()` 返回空,页面会尝试从 `supa.getSession()` 获取 auth id 并在 `ak_users` 表中查找对应的 `ak_users.id` 作为回退,这能修复 `driver_id` 在数据库中为 `ak_users.id` 的常见映射问题。 + +## 常见不一致现象说明 +- 我们观察到的常见情况:`ml_delivery_tasks` 中的任务显示为“已完成”(配送端),但对应 `ml_orders.order_status` 仍为“已取消”或其它状态,导致不同页面显示冲突。原因通常为: + - `ml_delivery_tasks.order_id` 为空或格式不一致(UUID vs string); + - `ml_orders` 没有相应行(数据尚未同步或被删除); + - RLS/权限导致前端不能读取或更新 `ml_orders`。 + +建议排查 SQL(例): +``` +SELECT t.id AS task_id, t.order_id, t.status AS task_status, o.order_status +FROM public.ml_delivery_tasks t +LEFT JOIN public.ml_orders o ON o.id = t.order_id +WHERE t.status >= 2 +ORDER BY t.created_at DESC +LIMIT 200; +``` + +如需我为你生成触发器或前端重试队列示例,我可以继续实现。 + +## 常见问题与排查步骤 +1. 问题:页面没有显示当前已接订单(即使首页显示有当前任务)。 + - 检查控制台日志:页面会打印 `loadOrderHistory: currentUserId=`、`loadOrderHistory: session id fallback=`、`loadOrderHistory: delivery_tasks dtRes=`、`loadOrderHistory: ordersRes=`。把这些日志逐项核对: + - `currentUserId` 应为 `ak_users.id`(或系统实际使用的 driver id)。 + - `dtRes`(delivery_tasks 查询)应包含对象数组,且数组项含 `order_id`。 + - `ordersRes` 应包含对应的 `ml_orders` 行。 + - 若 `dtRes` 为空且 `session id fallback` 有值,说明 `ak_users` 表中可能没有把 auth id 映射到 `ak_users.id`,需要把 `ak_users.auth_id` 填入或同步。 + - 若 `ordersRes` 为空,但 `dtRes` 非空,请检查 `ml_orders.id` 与 `ml_delivery_tasks.order_id` 的数据类型(例如 UUID vs string)以及 RLS 策略。 + +2. 问题:页面加载慢或时不时刷新。 + - 原因:多个页面在 `onShow`/`onLoad` 时发起多次 supa 查询,且 `supaReady` 恢复会话有时较慢,导致累积延迟。已在 `index.uvue` 增加了防抖与 `enableAutoRefresh` 开关来禁止自动刷新。 + - 排查:查看控制台是否有 `supaReady timeout/failed` 警告(若有,则说明会话恢复慢或失败)。 + +## 性能与安全建议 +- 若数据量大,请在后端做分页与聚合(只返回必要字段);避免一次性查询大量 `ml_orders` 字段。参见 `earnings.md` 中的后端接口建议。 +- 长期建议:修改 `components/supadb/aksupainstance.uts` 中会话恢复逻辑,让刷新在后台异步进行或提供可配置的超时策略,避免阻塞页面加载。 + +## 测试步骤(快速) +1. 使用测试用户(参见 `test-user-1_at_123.com.md`)创建一个 `ml_delivery_tasks` 记录,`driver_id` 对应当前司机,且 `status >= 2`。 +2. 在首页确认当前任务显示;然后打开“历史订单”页面,观察顶部是否显示该订单。若未显示,贴上控制台中 `loadOrderHistory:` 的相关日志给开发者排查。 + +## 变更历史 +- 2026-02-02:添加回退 mapping(session -> ak_users.id)、supaReady 超时保护的说明、调试日志建议及性能建议。 + +--- + +如需我把文档翻译为英文或生成 README 风格的一页说明,我可以继续补充。 +# order-history.uvue — 历史订单 + +## 概要 +显示配送员历史订单(已完成/已取消等),并支持按时间范围过滤、分页和插入刚完成订单以做到“即时显示”。 + +## 数据结构 +- `HistoryOrder` + - `order_no`, `id`, `status`, `delivered_at`, `total_amount`, `shop_name` + +## 关键方法 +- `loadOrderHistory({start,end,page,size})` + - 优先按 `ml_delivery_tasks` 中与 `driver_id` 相关的 `order_id` 拉取任务记录,再批量查询 `ml_orders` 获取详情。 + - 若后端支持直接按 `driver_id` 返回已完成订单则调用后端聚合接口更高效。 + +- `checkForNewCompletedOrder()` + - 从 `uni.getStorageSync('completed_order_for_history')` 读取并合并到 `orderHistory` 顶部,随后清除本地缓存键。 + +## DB 查询示例(伪 SQL / supa) +``` +const tasks = await supa.from('ml_delivery_tasks').select('order_id,delivered_at').eq('driver_id', driverId).eq('status', 4).order('delivered_at', { ascending: false }).limit(100).execute() +const orders = await supa.from('ml_orders').select('*').in('id', tasks.map(t=>t.order_id)).execute() +``` + +## 分页与筛选 +- 使用后端分页(`page/size`),前端仅负责渲染和“加载更多”。 +- 支持按日期区间和商家名模糊搜索。 + +## 注意事项 +- 当从本地合并刚完成订单时,去重逻辑必不可少(按 `order_no` 或 `id`)。 +- 对于大量历史数据,应依赖后端支持归档与按需加载。错误/异常应有兜底 UI(空状态/重试按钮)。 + diff --git a/pages/mall/delivery/doc/profile-edit.md b/pages/mall/delivery/doc/old(弃用)/profile-edit.md similarity index 100% rename from pages/mall/delivery/doc/profile-edit.md rename to pages/mall/delivery/doc/old(弃用)/profile-edit.md diff --git a/pages/mall/delivery/doc/profile.md b/pages/mall/delivery/doc/old(弃用)/profile.md similarity index 100% rename from pages/mall/delivery/doc/profile.md rename to pages/mall/delivery/doc/old(弃用)/profile.md diff --git a/pages/mall/delivery/doc/order-detail.md b/pages/mall/delivery/doc/order-detail.md deleted file mode 100644 index 31408d88..00000000 --- a/pages/mall/delivery/doc/order-detail.md +++ /dev/null @@ -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()` 做兼容处理。 - diff --git a/pages/mall/delivery/doc/order-history.md b/pages/mall/delivery/doc/order-history.md deleted file mode 100644 index b46ea431..00000000 --- a/pages/mall/delivery/doc/order-history.md +++ /dev/null @@ -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(空状态/重试按钮)。 - diff --git a/pages/mall/delivery/doc/需求文档(现用)/README.md b/pages/mall/delivery/doc/需求文档(现用)/README.md new file mode 100644 index 00000000..6a2de5a7 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/README.md @@ -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` diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/20260224_add_push_devices_and_notifications.sql b/pages/mall/delivery/doc/需求文档(现用)/db/20260224_add_push_devices_and_notifications.sql new file mode 100644 index 00000000..7bda18bc --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/20260224_add_push_devices_and_notifications.sql @@ -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 天未上报)。 +-- ===================================================================================== diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_express_notifications_send_status.sql b/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_express_notifications_send_status.sql new file mode 100644 index 00000000..9ce0b6dd --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_express_notifications_send_status.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_notify_queue_and_trigger.sql b/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_notify_queue_and_trigger.sql new file mode 100644 index 00000000..319776db --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/20260309_add_notify_queue_and_trigger.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/20260310_fix_express_notifications_on_conflict_message_id.sql b/pages/mall/delivery/doc/需求文档(现用)/db/20260310_fix_express_notifications_on_conflict_message_id.sql new file mode 100644 index 00000000..c7e540d0 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/20260310_fix_express_notifications_on_conflict_message_id.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/express_tracking_platform_upgrade.sql b/pages/mall/delivery/doc/需求文档(现用)/db/express_tracking_platform_upgrade.sql new file mode 100644 index 00000000..f3cd0548 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/express_tracking_platform_upgrade.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/消息推送后台_数据库表结构需求文档.md b/pages/mall/delivery/doc/需求文档(现用)/db/消息推送后台_数据库表结构需求文档.md new file mode 100644 index 00000000..f2d1e32c --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/消息推送后台_数据库表结构需求文档.md @@ -0,0 +1,101 @@ +# 消息推送后台 —— 核心数据库表结构需求文档 + +> **创建日期**:2026年03月16日 +> **模块归属**:商城发货与物流推送后台 +> **涉及架构**:Node.js Worker + Supabase (PostgreSQL) + UniPush 2.0 + +为支撑高可用、防丢失、可追溯的异步消息推送架构,本消息推送系统在底层(Supabase/PostgreSQL)摒弃了传统的 Redis 中间件依赖,直接利用关系型数据库的横表与触发器机制(Transactional Outbox 事务外箱模式)完成了高并发状态下的物流事件缓冲与分发。 + +本需求文档详细列出了参与推送闭环的 **5 张核心数据表** 的设计规范、字段说明与职责定位,是后端/DBA 开发与日常客诉运维排障的唯一真理依据。 + +--- + +## 整体库表流转全景 +消息在数据库中的物理游走路径为: +**`平台接收原生钩子`** $\rightarrow$ **`轨迹业务表入库`** $\rightarrow$ (触发器) $\rightarrow$ **`任务队列缓冲表`** $\rightarrow$ (合并订单/用户数据) $\rightarrow$ **`最终下发通知表`** $\rightarrow$ **`匹配设备表CID推送`** + +--- + +## 一、 原始通信日志表 (`platform_express_event_raw`) + +### 1. 表职责方向 +保存第一手的物流商(如快递100)Webhook 推送过来的原生 JSON 报文。不做任何业务过滤。 +- **核心价值**:排查“某单是不是底下的快递公司就没给我们发回调?”的第一现场。如果这里没有记录,绝不可能是本系统的 Bug。 + +### 2. 核心字段设计 (逻辑概述) +* `id` (UUID): 唯一自增主键。 +* `raw_payload` (JSONB): 快递100推过来的纯原生报文。 +* `created_at` (Timestamp): 平台接收到外网请求的确切服务器时间。 + +--- + +## 二、 规范化轨迹主表 (`platform_express_tracking_events` / `waybills`) + +### 1. 表职责方向 +用于商城订单域查询使用的结构化物流跟踪事件表。接收服务将 `event_raw` 里的 JSON 解包后,转化为本商城统一的枚举状态。 +- **机制特点**:这张表在发生 `INSERT` (即产生新轨迹节点) 时,会触发 PostgreSQL 内置触发器 `event_to_queue_trigger`。 + +### 2. 核心字段要求 +* `waybill_id` (UUID): 关联的运单主键。 +* `status_code` (VARCHAR): 规范化的签收/派件枚举状态码。 +* `event_text` (TEXT): 具体的物流节点描述文本(例:“北京市【朝阳区】,您的快递已由门卫代签收”)。 + +--- + +## 三、 异步缓冲队列表 (`notify_queue`) + +### 1. 表职责方向 +承接业务量暴增时的**削峰填谷**与**断电防丢**任务(本质是实现基于 DB 的 Message Queue)。 +- **流程规范**:触发器把满足条件(如需要给用户弹推送的节点)的事件主键,极速插入本表待命。后台的 `notify-worker.js` (消费者) 轮询查表消费。 + +### 2. 核心字段定义 +* `id` (UUID): 队列任务唯一标识。 +* `waybill_id` (UUID): 运单号,Worker 读取后利用它去关联查询出订单里的 `user_id` 和商品信息。 +* `status_code` (VARCHAR): 通知级别状态。 +* **`dedupe_key` (VARCHAR)**: 防重幂等键。防止因为相同事件重复回调产生两条推送。 +* **`process_status` (VARCHAR)**: 最核心的状态机! + * `pending`: 等待 Node.js Worker 消费。 + * `processing`: Worker 消费中(乐观锁占用)。 + * `processed`: 拼接业务数据完成,已成功放入推送大表,完美结束。 + * `failed`: 服务异常或订单找不到,抛弃处理。 +* `last_error` (TEXT): 消费报错时的堆栈。 + +--- + +## 四、 最终下发通知表 (`express_notifications`) + +### 1. 表职责方向 +面向客户端推送与“站内信展示”的实体记录表。这里存放的是已经完成业务数据组装(谁买的、买的啥、什么物流状态),只需调用第三方厂商接口往外发的纯粹“信件”内容。 +- **流程规范**:`push-server.js` 轮询此表进行真实推送。 + +### 2. 核心字段定义 +* `id` (UUID): 消息的主键ID。 +* `recipient_id` (UUID): 接收用户的标识 (商城买家ID)。 +* `event_text_safe` (TEXT): 已经组装好且脱敏的安全话术(例如:“[张*],您的Nike球鞋正在派送中”)。 +* **`send_status / push_status` (VARCHAR)**: 推送服务通道状态机。 + * `pending`: 已产生信件,排队等待网络 POST 请发给 UniPush 云函数。 + * `delivered`: 云函数返回 200,且厂家确认通道收录。 + * `failed`: 网络异常或设备CID已注销失效。 +* `provider_response` (JSONB): 如果推送失败,此处会存储华为/小米等厂商或推服务产生的物理失败原因。 + +--- + +## 五、 设备凭证绑定表 (`push_devices`) + +### 1. 表职责方向 +提供从“商城系统 `user_id`” 到“手机厂家原生通道标识 `cid`” 的 KV 路由映射能力。 +- **业务场景**:用户 App 在启动或大版本更新时,前端会通过 `uni.getPushClientId` 获取本设备的厂商识别码,通过接口上报并 UPSERT 写入此表。 + +### 2. 核心字段定义 +* `id` (UUID): 映射记录主键。 +* `user_id` (UUID): 系统业务逻辑里的用户身份标识。 +* **`cid` (VARCHAR)**: UniPush 2.0 分配在设备当前生命周期的绝对推送识别码(至关重要,一旦清空缓存/重装系统可能会变动,必须持续覆盖更新)。 +* `updated_at` (Timestamp): 用以判断该设备CID的活跃新鲜度。如果一个CID超过1年未更新导致 `push_status` 频繁 `failed`,后台可将其标记为离线沉默用户放弃网络调用。 + +--- + +## 附:业务表拓展准则 (Guidelines) +如果未来需要将这套机制从物流推送拓展至**“拼团成功通知”**或**“特价秒杀通知”**,只需要: +1. 复用 `express_notifications` 表,或者建立同层级类似的 `system_notifications` 表,保留 `push_status` 和 `recipient_id`。 +2. 复用 `push_devices` 路由查找。 +3. 把源头触发器挂载到新的交易表中即可。此五表闭环具备极佳的**水平扩展性**与**极低的中间件运维成本**。 \ No newline at end of file diff --git a/pages/mall/delivery/doc/需求文档(现用)/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql b/pages/mall/delivery/doc/需求文档(现用)/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql new file mode 100644 index 00000000..3783310c --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/db/绕过RLS的方案(慎用)/20260311_add_rpc_notify_get_order_recipients.sql @@ -0,0 +1,85 @@ +-- ===================================================================================== +-- RPC: notify-worker safe recipients lookup (RLS-safe) +-- +-- 背景: +-- - public.ml_orders 已开启 RLS,PostgREST 在未携带可解码 JWT 时,auth.uid() 为 NULL, +-- 直接 SELECT 会被策略过滤为 0 行,导致 notify-worker 报 “order not found for waybill”。 +-- - 在一些自托管场景中,Authorization: Bearer 可能因 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: +-- ===================================================================================== + +BEGIN; + +CREATE OR REPLACE FUNCTION public.notify_get_order_recipients( + p_order_id UUID DEFAULT NULL, + p_order_no TEXT DEFAULT NULL +) +RETURNS TABLE ( + id UUID, + order_no VARCHAR, + user_id UUID, + merchant_id UUID +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $func$ +DECLARE + headers_json JSON; + token TEXT; + expected_token TEXT; +BEGIN + -- 1) 读取请求头 token(PostgREST 会把 headers 放入 GUC request.headers) + expected_token := current_setting('app.notify_worker_token', true); + headers_json := NULLIF(current_setting('request.headers', true), '')::json; + IF headers_json IS NOT NULL THEN + token := headers_json->>'x-notify-worker-token'; + END IF; + + IF expected_token IS NULL OR expected_token = '' THEN + RAISE EXCEPTION 'server misconfigured: app.notify_worker_token is not set'; + END IF; + + IF token IS NULL OR token <> expected_token THEN + RAISE EXCEPTION 'permission denied: invalid x-notify-worker-token'; + END IF; + + -- 2) 参数校验 + IF (p_order_id IS NULL OR p_order_id::text = '') AND (p_order_no IS NULL OR btrim(p_order_no) = '') THEN + RAISE EXCEPTION 'p_order_id or p_order_no must be provided'; + END IF; + + -- 3) 返回映射(SECURITY DEFINER 可绕过 RLS;只返回最小必要字段) + RETURN QUERY + SELECT o.id, o.order_no, o.user_id, o.merchant_id + FROM public.ml_orders o + WHERE (p_order_id IS NOT NULL AND o.id = p_order_id) + OR (p_order_no IS NOT NULL AND o.order_no = p_order_no) + LIMIT 1; +END; +$func$; + +-- 默认收紧:撤销 PUBLIC,按需授予 anon/authenticated/service_role 执行权限。 +REVOKE ALL ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) FROM PUBLIC; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO authenticated; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT EXECUTE ON FUNCTION public.notify_get_order_recipients(UUID, TEXT) TO service_role; + END IF; +END $$; + +COMMIT; diff --git a/pages/mall/delivery/doc/需求文档(现用)/express_tracking_mock_platform.sql b/pages/mall/delivery/doc/需求文档(现用)/express_tracking_mock_platform.sql new file mode 100644 index 00000000..605c0a49 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/express_tracking_mock_platform.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/seed_platform_express_test_data.sql b/pages/mall/delivery/doc/需求文档(现用)/seed_platform_express_test_data.sql new file mode 100644 index 00000000..9f355f52 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/seed_platform_express_test_data.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/simulate_third_party_to_db.sql b/pages/mall/delivery/doc/需求文档(现用)/simulate_third_party_to_db.sql new file mode 100644 index 00000000..e47525d2 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/simulate_third_party_to_db.sql @@ -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; diff --git a/pages/mall/delivery/doc/需求文档(现用)/uni-push2_安卓联调与取CID说明.md b/pages/mall/delivery/doc/需求文档(现用)/uni-push2_安卓联调与取CID说明.md new file mode 100644 index 00000000..f8ac1e5b --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/uni-push2_安卓联调与取CID说明.md @@ -0,0 +1,124 @@ +# uni-push2 安卓联调与取 CID 说明 + +## 1. 目的与范围 + +本文档用于记录本项目在 **uni-app x(UVUE + UTS)** 下,对 DCloud **统一推送 uni-push2** 的安卓端最小化接入与验证流程,目标是完成闭环: + +1) 真机运行应用 +2) 获取 CID(clientId) +3) 在 uni-push2 控制台使用“指定 CID”推送 +4) 设备收到推送(通知栏/透传回调) + +> 说明:本文聚焦“能跑通验证链路”的最小方案;厂商通道(小米/华为等)的离线推送配置属于后续增强。 + +## 2. 关键结论(先看这个) + +- **调试基座 ≠ 你的应用**:在 HBuilderX 使用“运行到 Android 设备(标准基座)”拿到的 CID,归属是“基座应用”,因此在你的 uni-push2 应用里按 CID 推送会提示“CID 不存在或不属于此应用”。 +- 要让 CID 归属正确,必须使用 **云打包/本地打包** 生成的 **你自己的 APK**,安装到手机后再获取 CID。 +- 不依赖控制台日志获取 CID:为了避免无法抓取 logcat 的情况,已实现 **把 CID 写入本地存储**,并在 minimal 页面 **直接展示 + 点击复制**。 + +## 3. 环境说明 + +- 框架:uni-app x(UVUE + UTS) +- 平台:Android 真机(红米/小米系统 MIUI/HyperOS 等) +- 推送:DCloud uni-push2 + +## 4. 客户端实现(最小可用) + +### 4.1 App 启动初始化与监听 + +在应用启动时: + +- 调用 `uni.onPushMessage` 监听推送回调(用于验证是否收到推送/透传) +- 调用 `uni.getPushClientId` 获取 CID +- 将 CID 输出日志,同时写入本地存储 `uni_push2_cid` + +涉及文件: +- `App.uvue` + +注意事项(UTS 语法/类型限制): +- UTS 对 `Any`/`Any?` 较严格,不能随意使用 TS 写法(如 `(x as any)`) +- 对 `ret.cid` 这类对 `Any` 的直接属性访问可能编译失败,需要用 `UTSJSONObject` 或字符串兜底 + +### 4.2 minimal 页面展示 CID 并一键复制 + +为了不依赖 HBuilderX 控制台/adb: + +- 从本地存储读取 `uni_push2_cid` +- 页面展示 CID +- 点击 CID 触发 `uni.setClipboardData` 复制 + +涉及文件: +- `pages/minimal.uvue` + +## 5. 打包与安装(验证闭环必需) + +### 5.1 云打包 + +- 在 HBuilderX 执行 Android 云端打包 +- 打包成功后会得到下载链接 + +下载次数限制(常见问题): +- 云端下载链接可能存在短时间“下载次数超限” +- 规避策略: + - 尽量 **只下载一次并备份**(网盘/U盘/微信文件传输助手) + - 必要时更换网络/IP(例如手机热点) + - 仍受限时,重新云打包生成新下载链接 + +### 5.2 手机安装与启动 + +- 建议卸载旧的调试基座或旧包,避免混淆 +- 通过系统安装 APK +- 桌面无图标时(MIUI/HyperOS 常见): + - 在系统“应用列表/管理应用”中搜索应用名进入详情页打开 + - 或在应用抽屉里搜索应用名,长按拖到桌面 + +## 6. 获取 CID 的两种方式 + +### 6.1 推荐:从 minimal 页面直接复制(无需日志) + +步骤: +1) 安装云打包 APK +2) 打开应用进入 Minimal Test Page +3) 等待 10–30 秒(必要时重启一次 App) +4) 页面显示 CID,点击即可复制 + +### 6.2 备选:adb logcat 抓日志(需要电脑装 adb) + +- 电脑安装 Android SDK Platform-Tools +- 开启手机 USB 调试并授权 +- 使用 `adb logcat` 过滤关键词(如:`uni-push2`、`clientId`、`cid`) + +> 说明:不建议通过 HBuilderX “运行到 Android 设备(标准基座)”来取日志,因为会回到基座环境,CID 归属不正确。 + +## 7. 控制台推送验证步骤 + +1) 确认使用的 uni-push2 控制台账号/应用,与打包 APK 对应的 AppID 一致 +2) 在控制台选择“消息推送/测试推送 → 指定 CID” +3) 粘贴从 minimal 页面复制的 CID +4) 发送通知消息 + +如果提示“CID 不存在或不属于此应用”,按以下顺序排查: +- 是否仍在使用调试基座的 CID(需要用云打包 APK 重新取 CID) +- 是否账号/应用选错(AppID 不一致) +- 是否刚安装/首次启动(CID 同步可能有延迟,等 1–3 分钟再试) + +## 8. 厂商通道(小米等)说明(可选) + +- 厂商通道用于离线推送/更稳定通知 +- 若云打包勾选/启用小米推送模块但未配置参数,可能触发校验提示 +- 最小验证路径:先确保能完成“在线推送/控制台指定 CID”闭环,再按需要补齐厂商通道 + +## 9. 本次相关改动点(便于回溯) + +- `App.uvue` + - 初始化 `uni.onPushMessage` + - 获取 CID 并写入 `uni.setStorageSync('uni_push2_cid', cid)` +- `pages/minimal.uvue` + - 读取 `uni.getStorageSync('uni_push2_cid')` + - 展示 CID,点击复制 + - 修复 UTS 的 `Any?` 类型推断问题(避免强制标注 `any`) + +--- + +如需把“推送触达率/离线推送(小米/华为/OPPO/vivo)/通知样式与点击跳转”纳入需求范围,请在本文基础上补充对应厂商后台开通、证书参数与多通道策略。 \ No newline at end of file diff --git a/pages/mall/delivery/doc/需求文档(现用)/前端字段清单.md b/pages/mall/delivery/doc/需求文档(现用)/前端字段清单.md new file mode 100644 index 00000000..01c28795 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/前端字段清单.md @@ -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`(仅供客服/运维查看)并折叠显示。 +- 时间线组件需支持展开单条事件详情、证据大图预览与来源标注(承运方/聚合方)。 + +--- +(以上为前端字段与契约建议,实际字段名可与后端协商最终确认) diff --git a/pages/mall/delivery/doc/需求文档(现用)/后台页面设计说明.md b/pages/mall/delivery/doc/需求文档(现用)/后台页面设计说明.md new file mode 100644 index 00000000..3e93c427 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/后台页面设计说明.md @@ -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 开关默认关闭,且仅在测试环境允许启用 diff --git a/pages/mall/delivery/doc/需求文档(现用)/接口规范.md b/pages/mall/delivery/doc/需求文档(现用)/接口规范.md new file mode 100644 index 00000000..1b5f6e97 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/接口规范.md @@ -0,0 +1,516 @@ +# 配送对接接口规范(模拟三通一达后台 / Mock 承运方 Server) + +本文档用于定义“模拟承运方后台服务(Mock Server)”的接口与行为,供平台在未接入真实承运方前完成联调与测试。 + +本文定位: +- 规定 Mock Server 与平台后端之间的交互(Webhook 推送 + 控制面 API)。 +- 规定平台侧建议的统一事件模型(用于入库、时间线展示、监控告警)。 + +关联文档: +- `配送模块需求文档.md`:目标/范围/验收与必须的故障注入 +- `状态映射表.md`:状态码映射建议(Mock event_code -> 平台 status_code) +- `前端字段清单.md`:安卓/Web 时间线展示字段契约 + +包含两类接口: +1) Mock Server -> 平台:Webhook 事件推送(模拟承运方回调) +2) 平台/测试工具 -> Mock Server:控制面 API(创建运单、追加事件、运行场景、触发推送、查询轨迹) + +## 统一约定 +- 时间格式:ISO 8601(示例:"2026-02-05T10:30:00+08:00") +- 所有接口请求/响应均为 JSON,Content-Type: application/json +- 写操作建议携带 `request_id` 用于幂等与排查 +- 所有第三方回调应保存 `raw_payload` 用于审计 + +补充说明(安卓端 & Web 端使用场景): +- 本文主要规范 Mock Server 与平台后端的交互;安卓/Web 客户端不应直接调用 Mock Server。 +- Web 端联调时如需浏览器直连平台接口,应由平台网关配置 CORS(仅测试环境白名单),避免将 Mock Server 暴露给公网。 + +## 一、目标与约束 +- 目标:用可控数据模拟关键配送节点与异常场景,覆盖验签、幂等、乱序/重复、延迟等测试。 +- 约束:本规范服务于测试/预发布环境,不用于生产承运方直连。 + +## 二、核心数据模型(建议统一存储字段) +- `carrier`:承运方标识(例如:YUNDA、YTO、KDN) +- `tracking_no`:运单号 +- `event_id`:第三方事件唯一ID(用于去重) +- `event_time`:事件时间(ISO8601) +- `event_code`:第三方原始事件码(尽量不改写,用于审计与一致性对齐) +- `event_text`:第三方原始事件文案(前端时间线默认展示) +- `status_code`:平台统一状态(ORDER_PLACED, SHIPPED, IN_TRANSIT, OUT_FOR_DELIVERY, READY_FOR_PICKUP, DELIVERED, EXCEPTION, RETURNED) +- `node_name`:节点名称(中转站/网点/城市) +- `location`:节点位置描述(城市/网点地址,注意隐私) +- `description`:事件详细描述 +- `evidence_urls`:证据照片/签名链接数组 +- `estimated_arrival`:ETA(若承运方提供,可选) +- `raw_payload`:原始第三方 JSON(审计) +- `last_synced_at`:本地同步时间 + +## 三、接入模式 +- 平台接收 Mock Server 的 Webhook 推送(推荐)。 +- 平台亦可主动调用 Mock Server 查询轨迹(模拟轮询补偿)。 + +范围说明(避免误解): +- 若要实现“电子面单/在线下单自动获取运单号”,需要对接第三方(快递公司直连或聚合平台)提供的下单/面单接口。 +- 该类“下单/面单”接口不属于本 Mock Server 接口规范范围;本文只覆盖轨迹事件的推送/查询与相关验签、幂等与字段口径。 + +## 四、接口定义(示例) + +1) Mock Server -> 平台:事件推送(Webhook) +- URL: POST /webhook/express/status +- 描述: 承运方/聚合方主动推送运单事件(单条或批量事件)。平台应校验签名并返回 HTTP 200 表示成功接收。 +- HTTP 头部(建议): + - `Content-Type: application/json` + - `X-Client-Id`: 承运方或聚合方ID + - `X-Timestamp`: 推送时间戳(防重放) + - `X-Signature`: HMAC-SHA256(body + X-Timestamp) 使用双方共享 secret +- 回调示例(单事件): +{ + "tracking_no":"YD123456789", + "carrier":"YUNDA", + "event_id":"e_20260205_0001", + "event_code":"TRANSIT", + "event_text":"运输中", + "event_time":"2026-02-05T14:32:00+08:00", + "node_name":"北京分拨中心", + "location":"北京市朝阳区XXX", + "evidence_urls":[], + "raw_payload":{/* 原始承运方 JSON */} +} + +处理要求: +- 平台校验 `X-Signature` 和 `X-Timestamp`;若验签失败或时间差过大返回 4xx。若成功返回 HTTP 200。接收后异步完成映射与入库。 +- 幂等:基于 `event_id` 或 `tracking_no+event_code+event_time` 去重。 + +2) 平台 -> Mock Server:轨迹主动拉取(模拟轮询) +- URL: GET /mock/v1/track?tracking_no={tracking_no}&carrier={carrier} +- 描述: 平台主动查询单运单轨迹,返回事件数组。 +- 响应示例: +{ + "tracking_no":"YD123456789", + "carrier":"YUNDA", + "events":[ + {"event_id":"e1","event_code":"PICKED","event_text":"已揽收","event_time":"2026-02-04T18:00:00+08:00","node_name":"门店揽收网点","location":"北京市顺义"}, + {"event_id":"e2","event_code":"ARRIVAL","event_text":"运输中","event_time":"2026-02-05T14:32:00+08:00","node_name":"北京分拨中心","location":"北京市朝阳区"} + ] +} + +3) 平台:异常上报 API(用户/客服触发,平台侧,不属于 Mock Server) +- URL: POST /api/v1/delivery/express/report-exception +- 说明: 该接口为平台自身能力,Mock Server 不强制实现。 +- 请求示例: +{ + "tracking_no":"YD123456789", + "reported_by":"user", + "report_type":"damaged", + "description":"包裹外包装破损,有压痕", + "evidence_urls":["https://.../img1.jpg"] +} +- 平台可选地回传给真实承运方(若未来接入)并记录回传结果。 + +4) 平台/测试工具 -> Mock Server:控制面 API(建议实现) + +4.1 配置回调目标 +- URL: POST /mock/v1/config +- 描述: 设置 Mock Server 推送到平台的 Webhook 目标地址与验签参数。 +- 请求示例: +{ + "target_webhook_url":"https://api.yourplatform.com/webhook/express/status", + "client_id":"carrier_mock_yunda", + "secret":"shared_secret", + "default_carrier":"YUNDA" +} + +4.2 创建运单 +- URL: POST /mock/v1/waybills +- 描述: 创建一个 mock 运单,可由调用方指定或由服务生成。 +- 请求示例: +{ + "carrier":"YUNDA", + "tracking_no":"YD123456789", + "order_no":"202602050001" +} +- 响应示例: +{ + "carrier":"YUNDA", + "tracking_no":"YD123456789", + "created_at":"2026-02-05T14:00:00+08:00" +} + +4.3 追加事件(不推送,仅入库) +- URL: POST /mock/v1/waybills/{tracking_no}/events +- 请求示例: +{ + "event_id":"e_20260205_0002", + "event_code":"TRANSIT", + "event_text":"运输中", + "event_time":"2026-02-05T14:32:00+08:00", + "node_name":"北京分拨中心", + "location":"北京市朝阳区", + "evidence_urls":[] +} + +4.4 运行预置场景(生成一组事件并可选立即推送) +- URL: POST /mock/v1/waybills/{tracking_no}/run-scenario +- 请求示例: +{ + "scenario":"standard_delivered", + "push":true, + "inject":{ + "delay_ms":300, + "duplicate":0, + "out_of_order":false, + "bad_signature":false, + "timestamp_skew_seconds":0 + } +} + +4.5 触发推送(把该运单事件推到平台) +- URL: POST /mock/v1/waybills/{tracking_no}/push +- 请求示例: +{ + "mode":"all", + "inject":{ + "delay_ms":0, + "duplicate":1, + "out_of_order":true + } +} + +4.6 查询运单轨迹(Mock Server 自身查询接口) +- URL: GET /mock/v1/waybills/{tracking_no}/track +- 响应示例: +{ + "carrier":"YUNDA", + "tracking_no":"YD123456789", + "events":[ + {"event_id":"e1","event_code":"PICKED","event_text":"已揽收","event_time":"2026-02-04T18:00:00+08:00"} + ] +} + +4.7 健康检查 +- URL: GET /mock/v1/health +- 响应示例: +{"status":"ok"} + +## 五、字段映射与状态对照(示例) +- 平台统一状态 `OUT_FOR_DELIVERY` 映射关系示例: + - 韵达:承运方 `派送中` -> 平台 `OUT_FOR_DELIVERY` + - 聚合:原始 `deliver` -> 平台 `OUT_FOR_DELIVERY` + +状态一致性策略(尽量保持平台与第三方一致): +- 一致性定义:平台保存的 `event_code/event_text` 与 `raw_payload` 应与第三方可查询到的轨迹语义一致;平台对外展示的 `status_code` 必须由映射表确定性生成(同一承运方同一 `event_code` 在同一版本映射下得到同一 `status_code`)。 +- 字段保真:`event_code/event_text` 仅做透传与脱敏展示,不建议“为了统一文案”而改写原文;统一展示口径使用 `status_code` 的标签/颜色/筛选实现。 +- 映射表治理:映射表变更需要版本化(例如 `mapping_version`)并走发布流程;避免在生产环境频繁调整导致历史轨迹“同码不同态”。如必须调整,建议补充回放/回填策略以保证历史一致。 +- 回查纠偏(可选增强):平台定期或按需对“关键单/争议单”触发第三方轨迹回查;若发现缺失事件则补采入库;若发现语义冲突,优先保留原始事件并追加“平台侧纠偏事件/备注”,避免静默覆盖造成审计对不上。 + +## 六、验签与安全 +- 建议使用 HMAC-SHA256 签名,签名字段为 `body + X-Timestamp`,服务端使用共享 `secret` 校验。 +- 防重放:校验 `X-Timestamp` 与当前时间差(例如 < 5 分钟),并保存最近 N 个 `X-Signature` 或 `event_id` 用于去重。 + +## 七、幂等与重试策略 +- webhook:承运方可能重试多次,平台必须基于 `event_id` 或 (`tracking_no`+`event_code`+`event_time`) 去重。 +- 主动调用承运方接口失败时采用指数退避重试;关键操作(打单)建议入队异步重试并人工告警。 + +入库层(幂等去重、乱序处理)建议: +- 幂等目标:同一条事件无论推送/重试多少次,最终数据库只保留一条(或同一条被安全更新),避免重复入库造成时间线膨胀。 +- 幂等键优先级: + - 优先使用第三方提供的 `event_id`。 + - 若缺少 `event_id`,使用兜底组合键:`tracking_no + event_code + event_time`(必要时可补充 `node_name/location` 以降低碰撞)。 +- 数据库约束:建议在事件表上建立唯一约束(例如 `carrier + tracking_no + event_id` 或 `tracking_no + dedupe_key`),写入采用 Upsert/Insert-Ignore,应用层无需“先查再写”来保证并发安全。 +- 乱序处理:允许事件乱序写入(以免晚到的历史节点被丢弃);查询展示时按 `event_time` 排序生成时间线。 +- 状态不回退(平台侧可选规则):当先收到终态(如 `DELIVERED`)后又收到更早时间的在途事件时,不应将订单状态从终态回退;可采用“以最新 `event_time` 事件计算 current_status”或“按状态流转等级仅允许前进”的策略。 + +## 八、展示与 UI 要求(与前端对接点) +- 时间线按 `event_time` 展示,标注 `carrier` 来源与 `last_synced_at`。 +- 节点可展开查看 `description`、`node_name`、`location`、`evidence_urls`(签收照片/回单)。若无证据则隐藏预览。 +- 提供“查看第三方原文”链接,展示 `raw_payload`(仅客服/运维可见)。 + +平台对客户端返回建议(安卓/Web): +- 客户端请求应面向平台统一接口(例如:`GET /api/v1/delivery/express/track?order_no=...`),由平台返回统一的 `status_history`。 +- 响应中建议包含:`carrier`、`tracking_no`、`status`、`status_history`、`last_synced_at`,以及可选的 `eta`。 +- `raw_payload` 不建议下发给普通用户端;仅在 Web 客服/运维界面按权限下发并记录审计。 + +## 九、监控与告警 +- 指标:webhook 接收成功率、平均同步时延、同步失败率、超过阈值未更新的运单数。 +- 告警:承运方 5xx 增加、webhook 验签失败率异常、单运单长时间无更新。 + +## 十、示例 cURL(Webhook 验证场景模拟) +``` +curl -X POST https://api.yourplatform.com/webhook/express/status \ + -H "Content-Type: application/json" \ + -H "X-Client-Id: carrier_yunda" \ + -H "X-Timestamp: 2026-02-05T14:32:00+08:00" \ + -H "X-Signature: " \ + -d '{"tracking_no":"YD123456789","event_id":"e_20260205_0001","event_code":"TRANSIT","event_time":"2026-02-05T14:32:00+08:00","event_text":"运输中"}' +``` + +## 十一、日志与保留策略 +- 建议保留 `raw_payload` 与入库审计日志至少 30 天以便纠纷处理,审计记录包括接收时间、IP、头部信息与处理结果。 + +## 十二、兼容与扩展建议 +- 建议实现承运商 Adapter 层:承运方差异在 Adapter 层转换为统一模型,便于后续扩展新承运方或替换聚合方。 +- 若需签收照片或更高粒度信息,应优先与承运方签订企业级合同或直连,并在 UI 端明确注明凭证来源。 + +### 十二点一、平台后端适配架构(推荐实现方式) + +目标:当不同第三方(直连承运方/聚合平台)API 结构、鉴权方式、事件码不同的时候,平台侧**不修改核心业务与数据库结构**即可接入。 + +核心原则: +- **统一领域模型入库**:第三方差异在入库前完成映射;数据库保存统一事件字段 + `raw_payload`。 +- **Adapter 可插拔**:每家第三方实现一个 Adapter(验签/解析/查询/映射),核心服务只处理统一模型。 +- **接入入口两条线**:Webhook(推送)与 Poller(轮询补偿)最终都走同一条 `EventIngestService`。 + +模块划分(建议): +- `WebhookController`:接收回调、路由到对应 Adapter、快速返回 200(业务异步处理)。 +- `CarrierAdapter`(每家一个):验签、解析、字段映射、状态映射、(可选)轨迹查询。 +- `AdapterRegistry/Router`:按 `carrier` 或 `X-Client-Id` 选择 Adapter(支持灰度与多配置)。 +- `EventIngestService`:幂等去重、乱序处理策略、统一入库、写审计日志。 +- `TrackQueryService`:给安卓/Web 提供统一查询接口(只读),不透出第三方差异。 +- `PollerJob`:定时对“长时间无更新”的运单做轮询补偿(可按承运方频率配置)。 + +幂等与乱序建议(平台侧): +- 幂等键优先级:`event_id`(优先) > `tracking_no + event_code + event_time`(兜底)。 +- 入库:允许乱序写入;查询展示时按 `event_time` 排序。 +- 兼容缺字段:缺少 `event_id` 时必须使用兜底组合键;缺少 `location/node_name` 时 UI 降级仅展示 `event_text`。 + +### 架构图(Mermaid) +```mermaid +flowchart LR + subgraph Clients[客户端] + A[安卓 App] -->|GET 物流时间线| PAPI[平台 API] + W[Web/H5/PC] -->|GET 物流时间线| PAPI + end + + subgraph Platform[平台后端] + PAPI --> TQS[TrackQueryService\n统一查询] + TQS --> DB[(Logistics DB\nWaybill + TrackingEvent)] + + WH[WebhookController\n接收回调] --> REG[AdapterRegistry/Router\n按carrier或X-Client-Id路由] + REG --> AD1[YUNDA Adapter] + REG --> AD2[YTO Adapter] + REG --> AD3[Aggregator Adapter\n(快递鸟/快递100)] + + AD1 --> ING[EventIngestService\n幂等/映射/入库] + AD2 --> ING + AD3 --> ING + ING --> DB + + PJ[PollerJob\n轮询补偿] --> REG + PJ -->|queryTrack| AD1 + PJ -->|queryTrack| AD2 + PJ -->|queryTrack| AD3 + end + + subgraph ThirdParty[第三方/Mock] + MS[Mock Server 或真实承运方/聚合] -->|Webhook 推送| WH + end +``` + +### 回调时序图(Webhook 推送 -> 入库 -> 客户端展示) +```mermaid +sequenceDiagram + participant TP as Mock/第三方 + participant WH as WebhookController + participant REG as AdapterRegistry + participant AD as CarrierAdapter + participant ING as EventIngestService + participant DB as LogisticsDB + participant API as 平台查询API + participant APP as 安卓/Web + + TP->>WH: POST /webhook/express/status (headers + body) + WH->>REG: resolveAdapter(carrier/X-Client-Id) + REG->>AD: getAdapter() + WH->>AD: verify + parseWebhook(rawBody) + AD-->>WH: NormalizedEvent[] + WH-->>TP: 200 OK (快速返回) + WH->>ING: ingest(NormalizedEvent[]) + ING->>DB: upsert events (幂等去重) + + APP->>API: GET /api/v1/delivery/express/track?order_no=... + API->>DB: query events (order_no/tracking_no) + DB-->>API: events ordered by event_time + API-->>APP: timeLine(status_history + last_synced_at) +``` + +备注:Mermaid 图在不支持的 Markdown 渲染器中会降级为代码块,不影响内容阅读。 + +## 十三、附录:不同第三方 API 形态示例(用于理解差异) + +说明:以下为“常见形态示例”,用于帮助团队理解不同第三方接口在鉴权、字段与能力上的差异。 +- 不保证与任一承运方/聚合平台的官方文档 100% 一致;对接时必须以官方文档、沙箱与实际回文为准。 +- 推荐做法:在平台侧实现 Adapter/Mapper,把第三方差异映射为本文定义的统一事件模型再入库。 + +### 13.1 聚合平台常见形态(如快递100/快递鸟类) + +典型能力: +- 轨迹查询 API:传入 `company_code + tracking_no`(有些场景需要收件人手机号后四位) +- 订阅/推送:先订阅,后续以 Webhook 回调推送事件 +- 鉴权:常见 `appKey + sign`(MD5/HMAC)或 Token + +示例:轨迹查询(示例) +```http +POST https://api.aggregator.example.com/v1/track/query +Content-Type: application/json + +{ + "company_code": "yunda", + "tracking_no": "430123456789", + "phone_last4": "8000", + "nonce": "n_123", + "timestamp": 1738735200, + "sign": "md5_or_hmac_signature" +} +``` + +示例响应(示例) +```json +{ + "success": true, + "tracking_no": "430123456789", + "company_code": "yunda", + "state": "in_transit", + "events": [ + { "time": "2026-02-05T10:12:00+08:00", "context": "已揽收", "location": "深圳市" }, + { "time": "2026-02-05T22:01:00+08:00", "context": "到达广州分拨中心", "location": "广州市" } + ] +} +``` + +差异点: +- `state` / `status` 枚举不统一;事件字段可能是 `context/desc`、`time/timestamp`。 +- 不同快递公司可能要求手机号参与查询或订阅验证。 + +### 13.2 承运方直连常见形态(企业接口) + +典型能力: +- 轨迹查询、签收回单(POD)查询可能拆成多个接口 +- 鉴权更严格:HMAC、证书、IP 白名单或 OAuth2 + +示例:轨迹查询(示例) +```http +POST https://open.carrier.example.com/v2/route/query +Content-Type: application/json +X-Client-Id: your_client_id +X-Timestamp: 2026-02-05T10:30:00+08:00 +X-Signature: hmac_sha256(body + timestamp) + +{ + "tracking_no": "SF1234567890", + "include_pod": true +} +``` + +示例响应(示例) +```json +{ + "tracking_no": "SF1234567890", + "routes": [ + { "code": "PICKED", "desc": "已揽收", "time": "2026-02-05T09:10:00+08:00", "station": "深圳XX营业点" }, + { "code": "TRANSIT", "desc": "运输中", "time": "2026-02-05T21:45:00+08:00", "station": "广州分拨中心" } + ], + "pod": { "signed": false, "photo_url": null } +} +``` + +差异点: +- 字段更细(网点编码、操作员等),但不统一;POD 能力通常受权限/合同影响。 + +### 13.3 Webhook 推送常见形态(订阅后回调) + +典型能力: +- 订阅后由第三方主动推送轨迹事件到平台 +- 具备重试机制,因此平台必须做幂等去重、乱序处理 + +示例:回调事件(示例,字段名因第三方而异) +```json +{ + "tracking_no": "430123456789", + "carrier": "YUNDA", + "event_id": "evt_0001", + "event_time": "2026-02-05T22:01:00+08:00", + "event_text": "到达广州分拨中心", + "location": "广州市", + "extra": { "pod_photo": null } +} +``` + +接入建议: +- 无论第三方字段如何变化,平台入库前统一映射到本文“核心数据模型”,并保留 `raw_payload`。 +- 平台用 `event_id`(优先)或组合键(`tracking_no+event_code+event_time`)实现幂等。 + +### 13.4 圆通(YTO)物流轨迹查询接口(官方文档摘要) + +文档入口: +- https://open.yto.net.cn/interfaceDocument/menu251/submenu258 + +定位:该文档描述“根据圆通运单号查询物流轨迹”的接口形态,典型属于“平台主动查询(轮询)”模式。 + +关键交互要点(按官方文档整理,具体以控制台配置与最新文档为准): +- 传输:HTTPS,POST。 +- 报文结构:请求体包含 `timestamp`、`param`、`format`、`sign`。 +- `param`:以 JSON/XML 字符串承载业务参数;轨迹查询场景下包含圆通运单号字段(示例为 `NUMBER`,一次查询一个单号)。 + +签名规则(按文档描述抽象): +- 参与签名的明文:`param + method + v`(其中 `method` 与 `v` 来自控制台为该接口生成的配置)。 +- 将上述明文与客户密钥(`secret`)拼接后做 MD5,再对 MD5 的字节结果进行 Base64 编码,得到 `sign`。 +- 伪公式:`sign = base64(md5((param + method + v) + secret))` + +响应字段(按文档列举的 JSON 返回字段抽象): +- 运单号:`waybill_No` +- 走件时间:`upload_Time`(yyyy-MM-dd HH:mm:ss) +- 物流状态码:`infoContent`(示例枚举包括 GOT/ARRIVAL/DEPARTURE/SENT_SCAN/SIGNED 等) +- 物流信息文案:`processInfo` +- 城市/区县:`city`、`district`(可选) +- 重量:`weight`(可选) + +平台侧映射建议(把第三方差异收敛到统一模型): +- 时间:`upload_Time` -> 平台 `event_time` +- 文案:`processInfo` -> 平台 `event_text`(可另存 `description`) +- 状态:`infoContent` -> 平台 `event_code`(保留原码),再映射到平台统一 `status_code`(见 `状态映射表.md`) + +`infoContent` 到平台统一 `status_code` 的建议映射(示例): +- GOT(已揽收)-> IN_TRANSIT +- ARRIVAL(已收入/到达)-> IN_TRANSIT(中转/到达节点统一视为运输中) +- DEPARTURE(已发出/离开节点)-> IN_TRANSIT +- SENT_SCAN(派件)-> OUT_FOR_DELIVERY +- INBOUND(自提柜入柜)-> IN_TRANSIT(或按业务定义为 OUT_FOR_DELIVERY) +- SIGNED(签收成功)-> DELIVERED +- FAILED(签收失败)-> EXCEPTION +- TMS_RETURN(退回)-> RETURNED + +落库建议: +- 将圆通原始返回(整个数组或单条对象)保存到 `raw_payload`,便于客服/运维对照圆通官网。 +- 幂等去重:若第三方无稳定 `event_id`,可用组合键 `tracking_no + infoContent + upload_Time` 生成 `dedupe_key`。 + +### 13.5 韵达(YUNDA)开放平台(官方文档入口与调研清单) + +文档入口: +- https://open.yundaex.com/api/apiDoc + +说明:韵达开放平台文档页面存在较多交互式内容(需登录/控制台配置后才能看到每个接口的 `method/v/测试地址` 等关键信息)。因此本节先固化“对接时必须确认的要点清单”,避免对接过程中遗漏。 + +从官方文档导航可见的能力分类(用于判断覆盖范围): +- API 鉴权说明 +- 电子面单、散件下单 +- 物流轨迹 +- 售后服务、国际件、基础服务等 + +韵达轨迹对接需要在控制台确认/落盘的信息(建议形成《承运方接入配置表》): +- 鉴权方式:签名算法(HMAC/MD5 等)、参与签名字段、编码/排序规则、是否包含时间戳与 nonce。 +- 接口要素:轨迹查询接口 URL、`method`、`v`(如平台/第三方采用“method+version+param”体系)。 +- 订阅/回调能力:是否支持轨迹订阅与回调推送、回调重试策略、回调验签字段。 +- 返回字段:事件时间字段、事件码/状态字段、事件文案字段、地点字段(城市/网点)与可选 POD 能力。 + +平台侧落地方式保持不变: +- 无论韵达返回结构如何,统一通过 Adapter 映射为平台 `TrackingEvent` 领域模型入库,并保留 `raw_payload`。 + +附:Mock 控制面通用错误码(示例) +- 40001: invalid_parameter +- 40002: missing_required_field +- 40901: duplicate_request +- 50001: internal_error + +可选增强(非本期必需): +- 生成 OpenAPI 文档(控制面 API + Webhook 示例) +- 补齐“字段必填矩阵/容错矩阵”(配合 `drop_fields` 故障注入) diff --git a/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档.md b/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档.md new file mode 100644 index 00000000..528a8bb2 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档.md @@ -0,0 +1,111 @@ +# 推送与设备需求文档 + +本文档定义平台端基于 `push_devices` 与 `express_notifications` 的推送能力需求,覆盖设备 CID 的采集/管理、物流事件驱动的消息生成与送达、隐私与验收标准。 + +关联文档: +- 配送模块需求文档.md +- 接口规范.md +- 前端字段清单.md +- 状态映射表.md +- mall_sql/migrations/20260224_add_push_devices_and_notifications.sql + +## 一、目标 +- 保存每个用户/商家设备的推送 CID(支持多设备、多平台、多 appid)。 +- 在轨迹事件触发时,生成消息中心记录并向对应活跃设备下发推送(通知/透传)。 +- 提供消息幂等、未读/已读管理与审计能力,且严格控制敏感字段透传。 + +## 二、范围 +- 包含:设备注册/解绑、设备活跃检测、消息表写入、消息去重、对外推送触发接口与消息中心 API。 +- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。 + +## 三、术语 +- CID:Push 平台分配的 client id。 +- 设备:一台移动设备或浏览器实例,用 `cid + appid` 唯一标识。 +- 消息(Notification):由轨迹事件驱动的逻辑消息,写入 `express_notifications` 并可同步到消息中心与 Push 通道。 + +## 四、数据表与字段(概要) +说明:详细 DDL 请参考迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`。 + +- `push_devices` + - `id, user_id, merchant_id, cid, platform, appid, is_active, last_seen_at, registration_source, created_at, updated_at` + - 约束:同一 `appid` 下 `cid` 唯一;按 `user_id/merchant_id` 建索引。 + +- `express_notifications` + - `id, aud, recipient_id, order_id, waybill_id, tracking_no, carrier, message_id, event_text_safe, status_code, event_time, payload, read_at, dedupe_key, created_at, updated_at` + - 幂等:`message_id` 唯一(若存在)或 `dedupe_key` 在生成逻辑层保证不重复写入。 + +## 五、REST API 设计(建议) +注:API 端点为示例,具体实现放在平台后端网关下。 + +1) 设备管理 +- `POST /api/v1/push/register` — 注册/更新设备 + 请求体:`{user_id?, merchant_id?, cid, platform, appid, registration_source}` + 返回:设备记录 + +- `POST /api/v1/push/unregister` — 解绑设备 + 请求体:`{cid, appid, user_id?}` + +- `GET /api/v1/push/devices?user_id=...` — 列出某用户活跃设备 + +2) 消息与消息中心 +- `POST /api/v1/notifications/express/create` — 由后端服务写入消息并触发推送(通常由 EventProcessor 调用) + 请求体示例: + ```json + { + "aud":"user", + "recipient_id":"", + "order_id":"", + "waybill_id":"", + "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 diff --git a/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档_含建表附录.md b/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档_含建表附录.md new file mode 100644 index 00000000..3905dcff --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/推送与设备需求文档_含建表附录.md @@ -0,0 +1,276 @@ +# 推送与设备需求文档(含建表附录) + +本文档为 `推送与设备需求文档.md` 的拷贝,并在末尾追加 `express_tracking_platform_upgrade.sql` 的建表示例,便于 DBA / 后端直接参考与执行。 + +--- + +(以下为需求文档原文) + + +# 推送与设备需求文档 + +本文档定义平台端基于 `push_devices` 与 `express_notifications` 的推送能力需求,覆盖设备 CID 的采集/管理、物流事件驱动的消息生成与送达、隐私与验收标准。 + +关联文档: +- 配送模块需求文档.md +- 接口规范.md +- 前端字段清单.md +- 状态映射表.md +- mall_sql/migrations/20260224_add_push_devices_and_notifications.sql + +## 一、目标 +- 保存每个用户/商家设备的推送 CID(支持多设备、多平台、多 appid)。 +- 在轨迹事件触发时,生成消息中心记录并向对应活跃设备下发推送(通知/透传)。 +- 提供消息幂等、未读/已读管理与审计能力,且严格控制敏感字段透传。 + +## 二、范围 +- 包含:设备注册/解绑、设备活跃检测、消息表写入、消息去重、对外推送触发接口与消息中心 API。 +- 不包含:具体第三方推送厂商通道的离线能力(小米/华为等厂商通道配置为后续任务)。 + +## 三、术语 +- CID:Push 平台分配的 client id。 +- 设备:一台移动设备或浏览器实例,用 `cid + appid` 唯一标识。 +- 消息(Notification):由轨迹事件驱动的逻辑消息,写入 `express_notifications` 并可同步到消息中心与 Push 通道。 + +## 四、数据表与字段(概要) +说明:详细 DDL 请参考迁移脚本:`mall_sql/migrations/20260224_add_push_devices_and_notifications.sql`。 + +- `push_devices` + - `id, user_id, merchant_id, cid, platform, appid, is_active, last_seen_at, registration_source, created_at, updated_at` + - 约束:同一 `appid` 下 `cid` 唯一;按 `user_id/merchant_id` 建索引。 + +- `express_notifications` + - `id, aud, recipient_id, order_id, waybill_id, tracking_no, carrier, message_id, event_text_safe, status_code, event_time, payload, read_at, dedupe_key, created_at, updated_at` + - 幂等:`message_id` 唯一(若存在)或 `dedupe_key` 在生成逻辑层保证不重复写入。 + +## 五、REST API 设计(建议) +注:API 端点为示例,具体实现放在平台后端网关下。 + +1) 设备管理 +- `POST /api/v1/push/register` — 注册/更新设备 + 请求体:`{user_id?, merchant_id?, cid, platform, appid, registration_source}` + 返回:设备记录 + +- `POST /api/v1/push/unregister` — 解绑设备 + 请求体:`{cid, appid, user_id?}` + +- `GET /api/v1/push/devices?user_id=...` — 列出某用户活跃设备 + +2) 消息与消息中心 +- `POST /api/v1/notifications/express/create` — 由后端服务写入消息并触发推送(通常由 EventProcessor 调用) + 请求体示例: + ```json + { + "aud":"user", + "recipient_id":"", + "order_id":"", + "waybill_id":"", + "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 = '') + 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', '', '', '', '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 diff --git a/pages/mall/delivery/doc/需求文档(现用)/数据库对比与修改建议.md b/pages/mall/delivery/doc/需求文档(现用)/数据库对比与修改建议.md new file mode 100644 index 00000000..4d3f4086 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/数据库对比与修改建议.md @@ -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_*` 表。) diff --git a/pages/mall/delivery/doc/需求文档(现用)/物流消息推送方案_用户端与商家端.md b/pages/mall/delivery/doc/需求文档(现用)/物流消息推送方案_用户端与商家端.md new file mode 100644 index 00000000..2600efaa --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/物流消息推送方案_用户端与商家端.md @@ -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 说明 diff --git a/pages/mall/delivery/doc/需求文档(现用)/状态映射表.md b/pages/mall/delivery/doc/需求文档(现用)/状态映射表.md new file mode 100644 index 00000000..cf11b51a --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/状态映射表.md @@ -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` 一并存入事件记录(或运单聚合记录),对外查询接口可返回该版本用于排查。 +- 变更策略建议: + - 小改动优先“仅影响未来事件”(不回刷历史)。 + - 如必须修正历史映射,采用回放/回填任务,并对已对外展示的结果做备注或审计记录,避免静默改写。 + +代价与取舍: +- 会引入配置治理与发布流程的管理成本;若当前映射很稳定、承运方少、无强审计诉求,可以先不启用该方案。 diff --git a/pages/mall/delivery/doc/需求文档(现用)/生产表说明_platform_express.md b/pages/mall/delivery/doc/需求文档(现用)/生产表说明_platform_express.md new file mode 100644 index 00000000..61051d06 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/生产表说明_platform_express.md @@ -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` 升序组装时间线。 diff --git a/pages/mall/delivery/doc/需求文档(现用)/缺口与待补充清单.md b/pages/mall/delivery/doc/需求文档(现用)/缺口与待补充清单.md new file mode 100644 index 00000000..00105acf --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/缺口与待补充清单.md @@ -0,0 +1,82 @@ +# 缺口与待补充清单(配送模块|第三方轨迹对接 + Mock 联调) + +本文定位: +- 用“交付物清单”的方式回答:当前这套配送对接文档还缺什么、为什么缺、补了带来什么收益。 +- 不改变现有方案(统一事件模型 + Adapter + 幂等乱序入库 + 前端统一契约),只补齐落地时最容易卡住的材料。 + +关联文档: +- `README.md`:阅读入口与联调路径 +- `配送模块需求文档.md`:目标/范围/验收口径 +- `接口规范.md`:Webhook + 控制面 API + 入库建议 +- `状态映射表.md`:event_code/event_text -> status_code + 一致性治理(含备选方案) +- `前端字段清单.md`:安卓/Web 展示契约 + +--- + +## P0(建议本期补齐,影响联调与验收能否顺利推进) + +1) 联调/验收 Checklist(平台侧 + Mock 侧) +- 为什么要补:现在“验收标准”是目标描述,但缺少可勾选的操作性检查项,QA/对接会反复口头对齐。 +- 补齐收益:把“验签/幂等/乱序/重复/延迟/缺字段”等要求落到可执行项,减少漏测。 +- 建议产物:一份表格/列表(可以后续直接贴到测试用例/测试报告里)。 + +2) 场景库清单(scenario catalog)+ 每个场景的事件序列期望 +- 为什么要补:当前只提了 `standard_delivered` 等示例名,但没有定义“这个场景到底要生成哪些关键节点、每个节点对应哪些 event_code/status_code、时间间隔怎样”。 +- 补齐收益:前端可稳定对照 UI;后端可稳定断言幂等与排序;QA 可复现回归。 +- 建议产物: + - 场景列表(标准签收/拒收退回/地址异常/破损/丢失/长时间无更新/签收失败后成功等) + - 每个场景:事件数、关键事件码、是否包含 POD、是否包含异常、是否需要乱序/重复/延迟注入。 + +3) Webhook 接收契约的“ACK/重试”约定(平台与第三方的边界) +- 为什么要补:目前只写“成功返回 200”,但缺少“平台什么时候返回 2xx、什么时候 4xx/5xx、第三方是否重试、重试间隔与上限”的共识。 +- 补齐收益:避免线上/联调出现“平台处理慢导致第三方重推爆量”“平台 4xx 被第三方当作可重试”之类问题。 +- 建议产物:在 `接口规范.md` 增补一小节: + - 验签失败/缺字段/无法解析 -> 4xx(不建议重试) + - 平台内部暂时不可用 -> 5xx(允许重试) + - 成功接收但异步入库失败 -> 2xx 还是 5xx 的取舍(建议:接收成功就 2xx,入库失败走内部告警与补偿队列)。 + +4) 承运方接入配置表模板(Carrier Integration Profile) +- 为什么要补:你已经发现韵达/圆通等第三方细节差异大,缺少一个“把关键信息落盘”的标准表格,后续每接一家都要重新摸索。 +- 补齐收益:缩短接新承运方周期;把差异收敛到 Adapter/配置层;减少口头信息丢失。 +- 建议字段(模板): + - `carrier`、接入模式(webhook/polling/both) + - 鉴权类型与签名算法、参与签名字段、是否含 timestamp/nonce + - 轨迹查询接口(URL/method/v/字段示例)、回调接口(回调验签字段/重试策略) + - 是否提供稳定 `event_id`、是否要求手机号后四位 + - 时间格式/时区、地点粒度 + - 状态码枚举来源与映射版本 + +--- + +## P1(建议尽快补齐,影响长期维护与一致性/审计能力) + +1) 权限矩阵与审计要求(谁能看 raw_payload / POD / 全量地址手机号) +- 原因:`raw_payload` 可包含敏感字段;Web 客服/运维能力需要最小权限与审计。 +- 产物:角色(普通用户/商家/客服/运维/管理员)× 字段(raw/POD/手机号/地址)的可见性矩阵 + 审计日志要求。 + +2) 错误码与返回体统一(控制面 API) +- 原因:目前 `接口规范.md` 仅列举了示例错误码,但缺少标准响应结构。 +- 产物:统一响应:`{success, code, message, request_id, data}`;并约定 HTTP 状态码与业务码的对应。 + +3) OpenAPI(可选) +- 原因:接口多了以后靠 Markdown 容易漂移。 +- 产物:控制面 API + Webhook payload 的 OpenAPI(至少给 Mock 侧)。 + +4) “状态不回退/一致性”策略的明确算法(平台侧) +- 原因:`接口规范.md` 提了可选规则,但未选型;当出现终态后补到更早事件时,当前状态如何计算需要一口径。 +- 产物:选定一种算法并写清:以 `event_time` 最新为准 / 状态等级只进不退 / 双轨(展示按时间,current_status 不回退)。 + +--- + +## P2(可后补,锦上添花) +- DB 字典与 ER 图(便于新人理解 schema) +- 场景导入导出(JSON)格式规范 +- 对接灰度/熔断/降级策略的配置说明 + +--- + +## 建议补充顺序(最省时间) +1) 先补 P0-1(Checklist)+ P0-2(场景库清单)——直接提升联调效率 +2) 再补 P0-3(ACK/重试约定)——避免重复推送与误判 +3) 再补 P0-4(承运方接入配置表模板)——为后续接真实承运方铺路 +4) 最后补 P1 项——为线上长期治理做准备 diff --git a/pages/mall/delivery/doc/需求文档(现用)/配送模块需求文档.md b/pages/mall/delivery/doc/需求文档(现用)/配送模块需求文档.md new file mode 100644 index 00000000..ae191284 --- /dev/null +++ b/pages/mall/delivery/doc/需求文档(现用)/配送模块需求文档.md @@ -0,0 +1,317 @@ +# 配送模块需求文档(模拟三通一达后台 / Mock 承运方 Server) + +本文定位:定义 Mock 承运方 Server 的目标、范围、流程、故障注入与验收标准;接口与字段细节以 `接口规范.md`、`前端字段清单.md` 为准。 + +口径说明:本目录文档作为联调、验收与实现的唯一口径;如与口头沟通或临时讨论结论不一致,以本文档及其关联文档(接口/字段/映射)为准,并通过文档更新同步。 + +关联文档: +- `README.md`:阅读入口与联调路径 +- `接口规范.md`:Webhook 推送规范 + Mock 控制面 API +- `状态映射表.md`:event_code/event_text -> 平台 status_code +- `前端字段清单.md`:安卓/Web 时间线字段契约 + +## 1. 背景 +当前配送链路依赖第三方承运方(常见为“三通一达”等快递公司或聚合平台)。在真实承运方接口尚未签约/联调完成前,需要一个**可控的模拟承运方后台服务(Mock Server)**,用于开发与测试平台侧的:Webhook 接收、验签、幂等入库、时间线展示、异常处理与监控告警。 + +系统定位与边界: +- 本模块属于平台系统的一部分,最终呈现在“订单详情页”的物流区块,并同时服务客服/履约/对接排障。 +- 平台端需要查看订单的履约详情(至少包括:`order_no`、`tracking_no`、`carrier`、物流时间线 `status_history`、异常与处理记录)。 +- 但本需求不覆盖商品明细、支付、营销等订单业务域的展示与流程;这里聚焦“物流轨迹对接、入库与展示”。 + +多端归属与页面边界(统一口径): +- 本需求产出的“物流能力”不是一个独立的骑手/配送员端 App,而是作为同一套物流模块能力,分别嵌入到三个端(按权限展示不同颗粒度): + 1) 消费者端(C 端):订单详情的物流时间线/物流详情(只读为主,展示 `status_code` + 轨迹文案;可在“派送中”等末端环节提供“联系配送”能力,但涉及手机号等敏感信息需脱敏/虚拟号策略,且仅对收件人可见;不展示 `raw_payload`)。 + 2) 商家端(B 端):发货与运单绑定(选择承运方、录入/回填 `tracking_no`)+ 查看物流概览(以“到达某地/中转/派送/签收/异常”等大致进度为主;不展示配送员手机号等个人敏感信息;不包含对接配置与敏感调试信息)。 + 3) 管理端(平台后台:客服/履约/对接运维):订单/运单查询、Webhook 接收日志、接入配置、(可选)监控告警与审计能力。 +- 三端展示字段口径以统一事件模型与前端字段契约为准(见 `接口规范.md`、`前端字段清单.md`),避免出现“不同端展示不一致/状态口径不一致”。 +- 若未来要做“自营配送/同城骑手端”(接单、导航、到店取货、送达签收等),需单独立项与另写需求文档,不在本需求范围内。 + +敏感信息展示差异(必须遵循): +- 轨迹事件以同一份“事实数据源”入库与治理(`event_code/event_text/status_code` 等同源),但不同端的展示需按角色过滤与脱敏。 +- 配送员/快递员手机号: + - 商家端:不展示。 + - 消费者端:仅在末端环节(如 `OUT_FOR_DELIVERY`)按需提供,并必须脱敏(如 138****8000)或采用虚拟号/中转号方案;避免把第三方 `event_text` 原文中的手机号明文直接透传到推送消息或页面。 + +## 2. 目标 +- 适用阶段:主要用于真实第三方承运方未签约/未联调/不可控时的替代数据源;即便后续已接入真实承运方,也保留用于回归测试、故障注入与排障复现(生产环境默认关闭)。 +- 提供一个可配置的 Mock 承运方服务,模拟多承运方(圆通/韵达/中通/申通)轨迹事件与签收凭证。 +- 支持将轨迹事件按既定规范推送到平台 Webhook(模拟承运方 -> 平台)。 +- 支持主动查询轨迹(平台轮询场景)与“预置场景脚本”快速生成整条物流生命周期。 +- 支持故障注入:延迟、重复推送、乱序、签名错误、时间戳偏移、缺字段等,用于验证平台鲁棒性。 + +## 2.1 配送合作模式(不同解决方案) + +说明:以下方案是“商城履约/配送合作关系”的产品与工程取舍;无论选哪一种,只要前端要展示稳定一致的物流时间线,平台都建议建设统一的轨迹模型与查询接口(见 `接口规范.md` 与数据库 Schema)。本 Mock 体系用于在第三方未就绪或不可控时,替代外部系统快速联调与做故障注入回归。 + +### 方案 A:平台统一配送(平台与第三方合作 / 统采统接) +适用:希望提供强一致的履约体验、统一客服口径、统一监控与统计;平台愿意承担对接与运维成本。 + +平台职责: +- 统一签约/选择承运方或聚合平台;提供平台侧发货能力(下单、订阅/回调、轨迹查询与回单能力按合同)。 +- 平台统一事件模型入库(幂等去重、乱序处理、状态不回退),对客户端输出统一 `status_history`。 + +商家职责: +- 在平台内选择配送方案并发货;不需要自行对接承运方轨迹接口(或仅提供必要发货信息)。 + +优点:体验一致,平台可观测性强;扩展新承运方主要改 Adapter,不影响前端契约。 +风险/成本:平台对接与运维成本高;履约兜底责任更集中在平台侧。 + +与 Mock 的关系: +- Mock 用于第三方未联通阶段的替代数据源与故障注入;生产环境默认关闭。 + +### 方案 B:商家自选配送(商家自选承运商并发货 / 平台负责运单绑定与轨迹展示) +适用:平台招商、品类/区域差异大、平台希望给商家“选择承运方”的灵活性;平台不承担“实际配送执行”,但需要提供统一的运单绑定入口与轨迹展示底座。 + +重要说明(商家只是平台商户的常见情况): +- 默认不要求商家自建系统或自行对接第三方 API。 +- “商家自选”指商家在平台提供的承运方/聚合平台范围内进行选择,第三方对接、验签、幂等、入库与前端展示由平台统一承担。 + +平台职责(建议至少做到“统一展示底座”): +- 提供统一轨迹查询接口给前端;平台内部仍建议使用统一事件模型入库。 +- 平台负责与第三方承运方/聚合平台对接:订阅/回调(Webhook)或轨迹查询(轮询补偿)、验签、防重放、幂等去重、乱序处理与统一入库。 +- 平台提供“发货与运单绑定”的入口(承运方选择、运单号录入/回填/打单),把差异收敛在平台 Adapter/映射规则,避免前端直接适配多家第三方。 + +商家职责: +- 商家在平台的订单列表/订单详情页点击“发货/绑定运单”,选择承运方并录入/回填 `tracking_no`;`order_no` 由平台订单上下文自动带入,不要求商家手工输入订单号。 +- `ORDER_PLACED`(已下单)阶段允许 `tracking_no` 为空(前端展示“暂无运单号”);完成发货绑定后进入 `SHIPPED`,此时必须具备 `carrier + tracking_no`。 +- 若商家坚持使用“商家与第三方的独立合同/账号”,可向平台提交第三方对接所需材料,由平台统一配置与代接入(不要求商家自建回调服务)。 + +可选增强:商家在平台内“自助对接第三方”(无需商家开发) +- 定位:可选增强能力,非一期必做。 +- 目标:让商家在平台后台完成第三方账号授权/配置,从而在平台内完成下单打单、获取运单号、查询/订阅轨迹;平台仍统一入库与展示。 +- 常见两种形态(可二选一,也可并存): + 1) 聚合平台授权开通(推荐优先):平台统一对接一家聚合服务商;商家在平台内完成开通/授权后即可使用多家快递能力。 + 2) 商家自带第三方账号(BYO Account):商家已与某快递/聚合平台签约;在平台后台录入密钥或走 OAuth 授权,平台代为调用第三方 API。 +- 商家后台最小页面/流程建议: + - 【物流渠道管理】:选择渠道类型(聚合/直连)、开启/停用 + - 【授权与密钥】:录入/更新 `appKey/appSecret/token` 或 OAuth 绑定;展示“最后一次连通性检测”结果 + - 【回调配置提示】:展示平台 Webhook 地址与白名单要求(若第三方需要配置回调) + - 【测试与排障】:一键“连通性测试/拉取一条轨迹/模拟下单”,失败给出可读错误 +- 安全要求(必须): + - 第三方密钥加密存储、最小权限、变更审计;仅商家管理员可配置。 + - 不在前端暴露密钥;平台服务端代调用第三方接口。 + - 轨迹/运单数据仍写入平台统一表结构与事件模型,避免不同渠道把差异带到前端。 + +是否需要做(决策建议): +- 暂不需要(建议先不做)的场景:一期目标仅是“商家回填运单号 + 平台展示第三方轨迹关键节点”,且商家规模不大/对接资源有限。 +- 建议需要(做了收益明显)的场景:商家量大且运单号回填错误率高、客服投诉“查不到物流/不更新”多;或明确要上“电子面单/平台一键下单”。 +- 推荐分期: + - Phase 1:回填运单号 + 轨迹接入与统一展示底座(本项目当前优先级)。 + - Phase 2:先接入 1 家聚合平台做统一下单/面单/轨迹(降低多家直连成本)。 + - Phase 3:再开放 BYO Account(商家自带账号)自助配置(安全与运维成本最高)。 + +优点:相对方案 A 平台责任更轻、商家更灵活。 +风险/成本:体验碎片化风险大;如果不强制回传规范,平台客服/前端会被迫处理多样差异,长期维护成本更高。 + +#### 商家自选配送需要提供的内容(接入清单) + +基础信息(发货侧最小闭环): +- `carrier`:承运方标识(可为直连承运方或聚合平台,如 YUNDA/YTO/ZTO/STO/KDN 等)。 +- `tracking_no`:运单号生成与回传方式(商家生成/第三方返回/平台生成)。 +- 订单关联信息:`order_no`(或平台侧可解析的业务单号),用于把轨迹绑定到订单详情页;通常由平台在“订单发货/绑定运单”操作中自动带入,不要求商家手工录入。 + +运单号(`tracking_no`)获取方式(两种常见落地,二选一或并存): +1) 回填运单号(最小模式,推荐先上线): + - 商家在线下/快递官方系统/聚合平台完成下单与交接,获得运单号。 + - 商家在平台后台“发货/绑定运单”时选择承运商并回填 `tracking_no`(订单号无需手填,平台自动关联当前订单)。 + - 平台不需要调用第三方“下单/面单”接口,只需后续接入轨迹(Webhook/轮询)用于展示。 +2) 电子面单 / 在线下单(增强模式,体验更好): + - 平台(或平台集成的服务商)需要对接第三方提供的“下单/面单”接口,向第三方提交发货所需信息(收件人/地址/重量/件数等),并获得:运单号 `tracking_no` + 面单文件/面单号等。 + - 第三方可以是: + - 直连快递公司接口(每家快递一套协议);或 + - 快递聚合平台接口(一套协议覆盖多家快递)。 + - 兜底策略建议:若第三方下单失败/超时,允许商家改为“手工回填运单号”完成发货闭环。 + +轨迹数据接入方式(平台统一接入,推荐第一种): +1) 第三方 -> 平台 Webhook 推送(推荐): + - 平台与第三方完成订阅/回调配置;第三方直接回调平台 Webhook。 + - 平台统一完成:验签、防重放、字段映射、状态映射、幂等去重、乱序入库。 +2) 平台 -> 第三方 轮询拉取(可选补偿): + - 平台按承运方策略轮询“长时间无更新”的运单,补齐轨迹。 + - 适用于第三方不稳定、回调丢失或仅提供查询能力的场景,但平台运维成本更高。 + +(高级模式,可选,适用于有自建系统的大商家) +- 商家系统 -> 平台:商家按平台统一事件模型回传轨迹事件;平台仍执行幂等/乱序入库与审计。 + +对接材料(按商家选择的接入方式提供): +- 若商家仅在“平台已接入承运方范围内自选”,通常无需商家提供第三方接口材料。 +- 若商家使用“独立合同/账号”并要求平台代接入,则需要商家提供或协助申请: + - 鉴权信息:`appKey/appSecret`、`token`、证书、公网 IP 白名单等(因第三方而异)。 + - 回调能力:是否支持订阅 + Webhook(以及重试策略、签名算法、回调白名单)。 + - 能力说明:是否提供 `event_id`、是否提供 POD/签收凭证、是否需要手机号后四位参与查询等。 + +映射配置(用于消除差异化,平台侧必须落库为配置或代码映射): +- 事件码/文案 -> 平台 `status_code` 的映射(至少覆盖:揽收/在途/派送中/签收/异常/退回)。 +- 幂等去重策略:优先 `event_id`;缺失时使用组合键(见 `接口规范.md` 的入库层建议)。 + +#### 平台统一配送状态(`status_code`,必须遵循) + +说明:平台内部与对外输出统一使用同一套 `status_code`,三端(C/B/管理端)按需展示,不得随意改写状态口径。 + +状态枚举(当前统一口径): +- `ORDER_PLACED`:已下单(平台侧订单已创建;通常不是第三方快递事件;此阶段允许 `tracking_no` 为空) +- `SHIPPED`:已发货(已绑定运单/待揽收) +- `IN_TRANSIT`:运输中(所有中转/分拨/到达节点统一归类为运输中) +- `OUT_FOR_DELIVERY`:派送中 +- `READY_FOR_PICKUP`:待取件(驿站/自提柜等) +- `DELIVERED`:已签收 +- `EXCEPTION`:异常 +- `RETURNED`:退回/退件 + +展示规范: +- 为保证用户体验,应真实展示第三方回传的物流轨迹文案(如“到达北京分拨中心”、“离开上海网点”等),不做脱敏处理。 +- 前端时间线展示 `event_text` 原文,状态标签对应 `status_code`。 + +说明:本项目不维护“发货前”的配送状态;在未绑定运单阶段属于订单域状态,不纳入物流轨迹状态枚举。 + +补充约束(与页面展示相关): +- 当 `tracking_no` 为空时,前端仅展示订单的配送状态为 `ORDER_PLACED` 与“暂无运单号”,不展示可查询的第三方物流时间线;待商家完成“发货/绑定运单”后,再展示轨迹时间线。 + +#### 差异化来源与平台收敛策略 +差异化来源(商家自选必然存在): +- 鉴权差异:Token/HMAC/证书/IP 白名单等。 +- 字段差异:时间格式、地点粒度、是否有 `event_id`、异常字段命名等。 +- 状态差异:同一状态不同叫法/码值,甚至“派送/签收/退回”的边界不同。 +- 交互差异:仅查询(轮询)/订阅回调(Webhook)/两者并存;重试与乱序程度不同。 + +平台收敛策略(避免差异扩散到前端与数据库): +- 前端只消费平台统一查询接口与统一字段(见 `前端字段清单.md`),不得直接依赖第三方字段。 +- 平台统一入库模型与幂等/乱序规则(见 `接口规范.md`),第三方差异仅在 Adapter/配置映射层处理。 +- 分级能力与降级: + - 达标(可回传轨迹或可稳定拉取 + 通过验收)-> 展示完整时间线 + 可做告警统计。 + - 不达标(仅能提供运单号或字段缺失严重)-> 降级为“仅展示运单号/跳转外部查询”,不承诺完整时间线。 + +验收建议(商家自配准入): +- 必测:重复推送、乱序、延迟、验签失败、缺字段(对应本 Mock 的故障注入项)。 +- 通过后才允许全量展示/告警统计;未通过仅允许降级展示。 + +与 Mock 的关系: +- Mock 可作为“平台对接与回归”的验收工具:在第三方未就绪或不可控时,先用 Mock 场景验证幂等/乱序/缺字段容错等;接入真实第三方后也可用于故障注入回归。 + +### 方案 C:混合模式(平台默认合作 + 允许商家自配)【推荐】 +适用:既要平台可控的默认体验,又要给大商家/特殊品类保留弹性。 + +规则建议: +- 默认:商家走平台合作承运方(覆盖大多数订单,保证体验一致)。 +- 例外:允许商家自配,但必须满足平台回传规范(至少事件时间/文案/状态映射可得);否则只提供降级展示能力。 +- 前端:永远只消费平台统一查询接口,不直接依赖第三方字段。 + +落地建议(最小闭环): +- 平台统一事件表与查询 API 先上线;Mock 先跑通联调与回归。 +- Phase 1:接 1 家聚合或 1 家主力承运方(平台统一配送)。 +- Phase 2:开放商家自配(按准入与验收清单接入)。 + +## 3. 范围 +### 3.1 包含 +- Mock Server 对外提供控制面 API:创建/查询运单、追加事件、运行场景、触发推送、配置回调目标。 +- Mock Server 向平台推送 Webhook:按统一事件模型、支持 HMAC 验签头。 +- 事件与场景数据本地持久化(最小可用可内存;建议支持文件落盘,便于复现)。 + +### 3.2 不包含 +- 真实承运方业务能力(计费、真实网点、真实 POD 获取) +- 配送员实时定位与地图 +- 平台侧业务实现(本需求仅定义 Mock Server 的接口与行为) + +## 4. 目标用户 +- 平台后端开发:联调 Webhook、验签、幂等与入库。 +- 平台前端开发:使用固定运单/事件数据验证时间线展示。 +- QA 测试:用故障注入覆盖异常链路与回归。 +- 运维/对接:用于排查“回调未到/重复到/乱序到”等问题。 + +## 4.1 使用端(主要投放端) +- 安卓端(App):订单详情页展示物流时间线、签收凭证预览、异常上报。 +- Web 端(H5/PC 管理后台或用户 Web):同样展示物流时间线,提供更完整的原始事件/审计信息(按权限)。 + +## 4.2 平台后台展示建议(客服/履约/对接运维最小集) + +说明:本节定义“平台侧管理后台”在物流模块上建议具备的最小展示与排障能力(不等同于完整订单后台)。 + +1) 订单详情页(物流区块) +- 基础信息:`order_no`、`carrier`、`tracking_no`、当前 `status_code`、`last_synced_at` +- 物流时间线:`status_history`(按 `event_time` 排序,展示 `event_text`,标签使用 `status_code`) +- 异常提示:长时间无更新、签收失败、退回等(按 `status_code` 与规则触发) + +2) 运单/轨迹查询页(排障入口) +- 支持按 `tracking_no/order_no/carrier/status_code` 查询 +- 展示最近 N 条事件与来源(mock/承运方/聚合),便于快速定位 + +3) 对接日志与审计(运维/对接用) +- Webhook 接收日志:验签结果、去重命中、入库结果/错误原因(按权限) +- 原始回文:`raw_payload` 折叠展示(仅客服/运维可见,记录访问审计) + +4) 配置中心(对接运维用) +- 承运方/聚合平台配置、Webhook 目标与密钥/证书、轮询补偿开关与频率 +- 测试环境 Mock 开关(默认关闭,需显式开启) + +(可选增强)一致性回查/纠偏入口:按需触发第三方回查,补采缺失事件并保留审计记录。 + +## 4.3 商家后台展示建议(商户侧最小集) + +说明:商家只是平台商户时,建议仅提供“发货与运单绑定 + 物流查看”的最小能力。 + +- 发货:选择承运方、录入/回填 `tracking_no`、发货确认 +- 订单详情(物流区块):查看时间线与当前状态(不展示 `raw_payload`) + +端侧差异化要求: +- 安卓端需支持弱网/后台切换恢复后的快速刷新;证据图片需支持点击预览/下载。 +- Web 端需支持表格/筛选/搜索(便于客服排障);对调试信息(raw_payload)默认折叠。 + +## 5. 核心概念 +- 承运方(carrier):YUNDA / YTO / ZTO / STO(可扩展) +- 运单(tracking_no):可由 Mock Server 生成或由调用方指定 +- 事件(event):包含 event_id、event_time、event_code、event_text、node_name、location、evidence_urls 等 +- 场景(scenario):一组按时间顺序生成的事件脚本(如“标准签收”“拒收退回”“地址异常”) + +## 6. 关键流程 +1) 配置推送目标:设置平台 Webhook URL、`client_id`、`secret`。 +2) 创建运单:选择 carrier,生成/指定 tracking_no。 +3) 运行场景:Mock Server 生成一系列关键节点事件并入库。 +4) 触发推送:Mock Server 按顺序(或按乱序/延迟策略)将事件推送到平台。 +5) 平台侧验证:校验验签、去重、入库、前端展示与告警。 + +## 7. 功能需求 +### 7.1 控制面 API(Mock Server) +- 配置:设置 webhook 目标、密钥、默认承运方。 +- 运单:创建/查询/列表。 +- 事件:追加/查询/清空;支持批量追加。 +- 场景:运行预置脚本(标准流程/异常流程)。 +- 推送:立即推送指定运单的全部/增量事件;支持模拟重试与重复。 + +### 7.2 Webhook 推送(Mock -> 平台) +- 请求头包含 `X-Client-Id`、`X-Timestamp`、`X-Signature`。 +- body 结构遵循统一事件模型(见 `接口规范.md`)。 +- 幂等:对同一事件可重复推送,平台需以 `event_id` 去重。 + +### 7.3 故障注入(必须) +- `delay_ms`:推送延迟 +- `duplicate`:重复推送次数 +- `out_of_order`:乱序推送 +- `bad_signature`:签名错误 +- `timestamp_skew_seconds`:时间戳偏移 +- `drop_fields`:缺字段(用于校验平台必填校验与容错) + +## 8. 非功能需求 +- 易用性:一条命令启动;提供最少的配置即可发送事件。 +- 可复现:场景运行应可导出/导入(或提供固定随机种子)。 +- 可观测:每次推送记录 request/response 摘要与 request_id。 +- 安全:仅用于内网/测试环境;支持简单 Token 或 IP 白名单(可选)。 + +端侧体验要求(安卓/Web): +- 刷新策略:页面进入时拉取一次平台侧轨迹;用户可手动“刷新物流”;显示 `last_synced_at`。 +- 离线降级:无网络时展示上次缓存的时间线并提示“网络不可用”。 +- 权限与隐私:手机号脱敏;配送员/快递员手机号仅对收件人可见且需脱敏/虚拟号策略(商家端不展示);`raw_payload` 仅客服/运维可见并记录审计;不展示配送员精确地址/定位。 + +## 9. 验收标准 +- 能创建运单并运行“标准签收”场景,向平台推送至少 6 个关键节点(揽收/到达中转/在途/到达目的地/派送中/签收)。 +- 能注入重复与乱序事件,平台侧仍能正确去重并按时间线展示。 +- 能注入验签失败事件,平台侧能拒绝并记录告警。 + +## 10. 文档与交付物 +- `接口规范.md`:Mock Server 控制面 API + Webhook 推送规范 +- `状态映射表.md`:事件码/原文到平台统一状态映射建议 +- `前端字段清单.md`:前端展示字段契约(供时间线组件使用) +- 数据库 Schema:`mall_sql/schemas/express_tracking_mock_platform.sql`(平台统一轨迹入库 + Mock 承运方持久化表) + +## 11. 待补充项(备忘与下一步) +为保证联调、验收与后续接真实承运方不走弯路,建议按优先级补齐: +- `缺口与待补充清单.md` diff --git a/pages/mall/delivery/index.uvue b/pages/mall/delivery/index.uvue index cd916250..e93a9c87 100644 --- a/pages/mall/delivery/index.uvue +++ b/pages/mall/delivery/index.uvue @@ -97,8 +97,6 @@ 附近订单 🔄 刷新 - - 更多 ➜ @@ -107,34 +105,41 @@ 请保持在线状态,有新订单会自动推送 - - - {{ order.order_no }} - ¥{{ order.delivery_fee }} - - - - - 📍 - {{ order.pickup_address.area }} + + + + {{ order.order_no }} + ¥{{ order.delivery_fee }} - - - 🏠 - {{ order.delivery_address.area }} + + + + 📍 + {{ order.pickup_address.area || order.pickup_address.detail }} + + + + 🏠 + {{ order.delivery_address.area || order.delivery_address.detail }} + + + + + 距离: {{ order.distance }}km + 预计: {{ order.estimated_time }}分钟 + 下单: {{ formatTime(order.created_at) }} + + + + + - - - 距离: {{ order.distance }}km - 预计: {{ order.estimated_time }}分钟 - 下单: {{ formatTime(order.created_at) }} - - - - - - + + + + + 查看全部订单 (共 {{ availableOrders.length }} 个待接订单) ➜ @@ -308,7 +313,10 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' } } 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) { console.error('loadDriverInfo error', e) @@ -397,6 +405,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' console.log('loadAvailableOrders: query result=', res) if (res && Array.isArray(res.data)) { const fetched = (res.data as Array).map((r:any) => this._transformTask(r)) + console.log('loadAvailableOrders: transformed count=', fetched.length) // 再次检查 currentTask,避免并发情况下短暂展示可接单 if (this.currentTask) { this.availableOrders = [] @@ -434,6 +443,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' return { id: task.id, + order_id: task.order_id || task.orderId || task.orderId || '', order_no: task.order_no || task.orderNo || task.trade_no || '', status: Number(task.status) || 1, pickup_address: parseAddress(task.pickup_address), @@ -465,8 +475,34 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' // 切换工作状态 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) { this.startWork() } else { @@ -475,8 +511,15 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' }, // 开始工作 - startWork() { - // TODO: 调用API开始工作,上传位置 + async startWork() { + 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() uni.showToast({ title: '已上线接单', @@ -485,8 +528,15 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' }, // 停止工作 - stopWork() { - // TODO: 调用API停止工作 + async stopWork() { + 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 = [] uni.showToast({ title: '已下线休息', @@ -631,6 +681,29 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' if (res && !res.error) { const completedOrder = { ...this.currentTask } 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' }) this.currentTask = null 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() if (res && !res.error) { uni.showToast({ title: '订单已接受', icon: 'success' }) + // 同步更新 ml_orders 状态为已接取(2) + try { + // orderId 这里是 ml_delivery_tasks.id(task id),需要先获取 order_id + const tRes: any = await supa.from('ml_delivery_tasks').select('order_id').eq('id', orderId).limit(1).execute() + if (tRes && Array.isArray(tRes.data) && tRes.data.length > 0) { + const oid = tRes.data[0].order_id + if (oid) { + const upRes: any = await supa.from('ml_orders').update({ order_status: 2 }).eq('id', oid).execute() + console.log('acceptOrder: ml_orders update res=', upRes) + if (!upRes || upRes.error) console.warn('acceptOrder: ml_orders update failed', upRes) + } + } + } catch (syncErr) { + console.warn('acceptOrder: failed to sync ml_orders status', syncErr) + } await this.loadCurrentTask() await this.loadAvailableOrders() } @@ -728,7 +816,7 @@ import { getCurrentUserId, getCurrentUser } from '@/utils/store.uts' } - diff --git a/pages/mall/delivery/test/consumer-logistics-detail.uvue b/pages/mall/delivery/test/consumer-logistics-detail.uvue new file mode 100644 index 00000000..894a5276 --- /dev/null +++ b/pages/mall/delivery/test/consumer-logistics-detail.uvue @@ -0,0 +1,521 @@ + + + + + diff --git a/pages/mall/delivery/test/consumer-order-list.uvue b/pages/mall/delivery/test/consumer-order-list.uvue new file mode 100644 index 00000000..f837830c --- /dev/null +++ b/pages/mall/delivery/test/consumer-order-list.uvue @@ -0,0 +1,239 @@ + + + + + diff --git a/pages/mall/delivery/test/index.uvue b/pages/mall/delivery/test/index.uvue new file mode 100644 index 00000000..b3630bbb --- /dev/null +++ b/pages/mall/delivery/test/index.uvue @@ -0,0 +1,133 @@ + + + + + diff --git a/pages/mall/delivery/test/merchant-order-detail.uvue b/pages/mall/delivery/test/merchant-order-detail.uvue new file mode 100644 index 00000000..bf0888f9 --- /dev/null +++ b/pages/mall/delivery/test/merchant-order-detail.uvue @@ -0,0 +1,294 @@ + + + + + diff --git a/pages/mall/delivery/test/merchant-order-list.uvue b/pages/mall/delivery/test/merchant-order-list.uvue new file mode 100644 index 00000000..b98df701 --- /dev/null +++ b/pages/mall/delivery/test/merchant-order-list.uvue @@ -0,0 +1,416 @@ + + + + + diff --git a/pages/mall/delivery/test/mock-service.uts b/pages/mall/delivery/test/mock-service.uts new file mode 100644 index 00000000..aec2347f --- /dev/null +++ b/pages/mall/delivery/test/mock-service.uts @@ -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 { + 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 | null = null + if (Array.isArray(d1)) { + resultArray = d1 as Array + } else if (d1 != null && (d1 as any).data && Array.isArray((d1 as any).data)) { + resultArray = (d1 as any).data as Array + } + + // 方案 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) : ((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).length > 0) { + ml = (mlData as Array)[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 | 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).length > 0) { + ml = (mlData as Array)[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 { + 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).length > 0) { + const row = (b1 as Array)[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).length > 0) { + const row = (b2 as Array)[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) + } + + // 如果按 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) + } + } + 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).length > 0) { + return this.mapToEvents(directEvents as Array) + } + + return [] + } + + /** + * 管理/调试用:获取所有运单(不做用户过滤) + */ + async getAllOrders(): Promise { + 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 + 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).length === 0) { + console.error('Scenario Error: Waybill not found', trackingNo) + return + } + + const waybill = (waybills as Array)[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): 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 { + // 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).length > 0) { + // 更新 + const waybillRow = (existingWaybills as Array)[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 { + // 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).length === 0) { + return false + } + + const waybill = (waybills as Array)[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 { + 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 | 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 | 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 { + 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 + 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 { + 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() diff --git a/pages/mall/delivery/test/platform-config-center.uvue b/pages/mall/delivery/test/platform-config-center.uvue new file mode 100644 index 00000000..656e5945 --- /dev/null +++ b/pages/mall/delivery/test/platform-config-center.uvue @@ -0,0 +1,204 @@ + + + + + diff --git a/pages/mall/delivery/test/platform-tracking-query.uvue b/pages/mall/delivery/test/platform-tracking-query.uvue new file mode 100644 index 00000000..b138a712 --- /dev/null +++ b/pages/mall/delivery/test/platform-tracking-query.uvue @@ -0,0 +1,300 @@ + + + + + diff --git a/pages/mall/delivery/test/platform-webhook-logs.uvue b/pages/mall/delivery/test/platform-webhook-logs.uvue new file mode 100644 index 00000000..307b0c31 --- /dev/null +++ b/pages/mall/delivery/test/platform-webhook-logs.uvue @@ -0,0 +1,263 @@ + + + + + diff --git a/pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md b/pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md new file mode 100644 index 00000000..2be89f0b --- /dev/null +++ b/pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md @@ -0,0 +1,84 @@ +**概述** +- **目标**: 让快递合作方(例如韵达)能安全、可靠地向系统的 `/webhook/express/status` 发送回调,用于运单状态同步与推送触发。 +- **范围**: 包含鉴权/验签约定、幂等策略、测试样例、部署与运维建议。 + +**接入概览** +- **Endpoint**: `https:///webhook/express/status` +- **协议**: HTTPS(TLS 必需) +- **数据格式**: `application/json`,body 为第三方原始回调 JSON。 + +**鉴权与验签** +- **方式**: 每个合作方分配一份 HMAC secret(UTF-8 文本),服务端按 `client_id` 查秘钥(建议把 `client_id` 放在请求头 `X-Client-Id`)。 +- **签名头**: `X-Timestamp`(UTC 秒)与 `X-Signature`(hex 小写)。 +- **签名算法**: HMAC-SHA256(secret, rawBodyText + timestamp) → hex。 +- **时窗**: 接收端按 ±300 秒(可配置)判定时间窗,超时拒绝(防重放)。 +- **严格模式**: `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 时签名不合将直接 401(上线初期可先设 false,观测后切为 true)。 + +**幂等与去重** +- **推荐字段**: 合作方可在 body 中提供 `dedupe_key`(优先使用);若无,服务端基于稳定字段计算哈希(建议用 `tracking_no|carrier|event_time|event_code` 组合)。 +- **数据库约束**: `platform_express_tracking_events` 应有唯一约束 `(waybill_id, dedupe_key)`,重复回调不再写入事件表。 +- **返回语义**: 对重复回调可返回 200 并在 body 标注 `duplicate: true`,以便合作方不再重试。 + +**请求/响应与重试策略** +- **接收端快速 ACK**: 推荐返回 HTTP 202 Accepted(或 200)表示“已入队/接收”,不代表处理完成。 +- **错误返回**: 4xx 表示请求问题(签名/格式),5xx 表示服务端异常(合作方可按重试策略重试)。 +- **建议重试规则**(对方遵守): 3–5 次,指数退避(例如 1m, 2m, 4m, ...),总重试时长不超过 1 小时。 + +**示例:签名与发送(bash)** +```bash +TS=$(date -u +%s) +BODY='{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}' +SECRET='YOUR_PARTNER_SECRET' +SIG=$(printf '%s%s' "$BODY" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256) +curl -v -X POST https://your-domain/webhook/express/status \ + -H 'Content-Type: application/json' \ + -H "X-Timestamp: $TS" \ + -H "X-Signature: $SIG" \ + -H "X-Client-Id: partner-yd" \ + -d "$BODY" +``` + +**示例:PowerShell(Windows)** +```powershell +$ts = [int][double]::Parse((Get-Date).ToUniversalTime().Subtract([datetime]'1970-01-01').TotalSeconds) +$body = '{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}' +$secret = 'YOUR_PARTNER_SECRET' +$hmac = New-Object System.Security.Cryptography.HMACSHA256([Text.Encoding]::UTF8.GetBytes($secret)) +$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($body + $ts)) +$sig = ($hash | ForEach-Object { $_.ToString("x2") }) -join '' +Invoke-RestMethod -Uri 'https://your-domain/webhook/express/status' -Method Post -Body $body -ContentType 'application/json' -Headers @{ 'X-Timestamp'=$ts; 'X-Signature'=$sig; 'X-Client-Id'='partner-yd' } +``` + +**测试与沙箱** +- **临时隧道**: 联调期可使用 `ngrok` 或 `cloudflared` 将本地服务映射到公网供合作方发送测试回调。 +- **测试密钥**: 为每个合作方发放 `test_secret` 与 `prod_secret`,并在系统中分离管理。 +- **回放工具**: 提供一个简单 `test-send.js`(仓库已有)并在 README 中示例化如何运行。 + +**部署与网关建议** +- **域名与 TLS**: 使用 `webhook.`,证书推荐用 Let's Encrypt 自动续期。 +- **反向代理 / 网关**: 推荐放在 Nginx / Kong / Cloud Load Balancer 前做: + - TLS 终端、IP 白名单、速率限制(rate-limiting)、日志落盘与审计。 + - 如使用 Kong,可在网关做 `key-auth` 或 IP 白名单做第一道防护。 +- **WAF/防护**: 对外公开时开启基础 WAF(过滤常见攻击)与 DDOS 保护。 + +**监控与告警** +- **关键 Metric**: 接收量、验签失败率、重复率(duplicate percent)、处理延时、no-targets 率。 +- **告警阈值**: 验签失败率 >1% 或 no-targets 突增 触发 PagerDuty/邮件告警。 + +**上线流程(最小安全步骤)** +1. 在测试环境配置 partner test_secret 与 `X-Client-Id`,提供示例脚本。 +2. 使用 `ngrok` 联调,通过 2–3 次真实回调验证 `dedupe_key`、waybill 匹配和入队。 +3. 把 partner 的 prod_secret 写入生产密钥存储(避免明文在 repo)。 +4. 在生产网关启用 IP 白名单或 key-auth,并在低流量窗口把 `WEBHOOK_REJECT_INVALID_SIGNATURE` 改为 `true`。 + +**常见问题与排查** +- **验签失败**: 检查是否对方用了 stringify 后的 body 或时间戳单位不一致(秒 vs 毫秒);建议双方按示例脚本校验。 +- **重复回调仍写入**: 检查 `dedupe_key` 的字段组合与 DB 唯一约束 `(waybill_id,dedupe_key)` 是否生效。 +- **no-targets**: 确保 `push_devices` 已按 `merchant_id` 或 `user_id` 正确写入(见 `GET /api/v1/push/devices` 验证)。 + +**联系人与上游联调清单** +- **提供方需给出**: 回调样例(真实 JSON)、回调频率预估、回调 IP 段、是否支持 `dedupe_key` 字段、联系人邮箱/电话。 +- **我方需要提供**: `test_secret`、测试域名、示例脚本、接入说明(本文件)。 + +--- +作者:自动生成;可根据合作方反馈补充示例与节流策略。 diff --git a/pages/mall/delivery/webhook-server/README.md b/pages/mall/delivery/webhook-server/README.md new file mode 100644 index 00000000..f5e8e02a --- /dev/null +++ b/pages/mall/delivery/webhook-server/README.md @@ -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 `,默认 `false`。 + - 在一些自托管 Supabase/Kong(key-auth)环境中,**只需要** `apikey`;如果误加 Bearer 且 key 不是 JWT,可能出现 `PGRST301`("None of the keys was able to decode the JWT")。 +- `WEBHOOK_SECRET`(可选):与第三方共享的 HMAC-SHA256 secret,用于校验 `X-Signature`(签名为 hex) +- `WEBHOOK_REJECT_INVALID_SIGNATURE`(可选):若为 `true`,且配置了 `WEBHOOK_SECRET`,则验签失败会直接返回 HTTP 401(默认不拒绝,只记录)。 +- `WEBHOOK_PORT`(可选):接收器监听端口,默认 `7201`(推荐用这个,便于与 push-server 共享同一份 `server/config.json`) +- `PORT`(可选):接收器监听端口(兼容旧用法;若共享 `server/config.json` 且其中 `PORT=7301`,会导致端口冲突) + +配置方式(推荐用配置文件,避免与其他服务端口冲突): +- **同目录配置文件(推荐)**:在 `webhook-receiver.js` 同目录放置 `webhook.config.json`,启动时会自动读取。 +- **显式指定配置文件**:设置 `CONFIG_FILE` / `CONFIG_PATH` 指向你的 `.env` 或 `.json`。 +- **回退加载**:若未指定 `CONFIG_FILE` 且同目录无 `webhook.config.json`,会尝试读取 `server/.env` 与 `server/config.json`(由 `server/load-config.js` 负责)。 + +示例配置文件:仓库内提供 [server/webhook.config.json.example](server/webhook.config.json.example),你可以复制一份到本目录作为 `webhook.config.json` 使用。 + +启动(PowerShell): +```powershell +node pages/mall/delivery/webhook-server/webhook-receiver.js +``` + +推荐:使用同目录配置文件启动(PowerShell): +```powershell +# 复制示例并填写真实 SUPA_KEY +Copy-Item .\server\webhook.config.json.example .\pages\mall\delivery\webhook-server\webhook.config.json + +node pages/mall/delivery/webhook-server/webhook-receiver.js +``` + +如果你使用显式 CONFIG_FILE: +```powershell +$env:CONFIG_FILE=(Resolve-Path .\pages\mall\delivery\webhook-server\webhook.config.json) +node pages/mall/delivery/webhook-server/webhook-receiver.js +``` + +启动(Linux / macOS / WSL): +```bash +export SUPA_URL='http://192.168.1.62:18000' +export SUPA_KEY='your_service_role_key' +export WEBHOOK_SECRET='your-secret' # optional +node pages/mall/delivery/webhook-server/webhook-receiver.js +``` + +也可以用配置文件(更适合长期运行): +- `server/load-config.js` 会自动尝试加载:`server/.env`、`server/config.json`(以及 `CONFIG_FILE/CONFIG_PATH` 指定的文件),并把其中的键注入到 `process.env`。 +- 因为接收器已在启动时 `require` 了该加载器,所以你只要把 `SUPA_URL`、`SUPA_KEY` 写进上述文件之一即可。 + +如果你不想与 `server/config.json` 共用 `PORT`(避免端口冲突),建议为 webhook 单独准备一个配置文件,然后用 `CONFIG_FILE` 指定: + +```powershell +# 复制示例文件并填写真实 SUPA_KEY: +Copy-Item .\server\webhook.config.json.example .\server\webhook.config.json + +# 指定配置文件启动(不会影响 push-server 的配置): +$env:CONFIG_FILE=(Resolve-Path .\server\webhook.config.json) +node pages/mall/delivery/webhook-server/webhook-receiver.js +``` + +示例:在 `server/.env` 中写入: +```env +SUPA_URL=http://192.168.1.62:18000 +SUPA_KEY=your_service_role_key +WEBHOOK_SECRET=your-secret +PORT=7201 +``` + +测试(curl 模拟第三方推送): +```bash +BODY='{"mailNo":"TEST_123","infoContent":"SENT","remark":"派送中","acceptTime":"2026-02-25 12:00:00","carrier":"YTO"}' +TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) +# 如果启用了 WEBHOOK_SECRET,计算签名: +SIG=$(printf "%s%s" "$BODY" "$TS" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}') + +curl -i -X POST http://localhost:7201/webhook/express/status \ + -H "Content-Type: application/json" \ + -H "X-Timestamp: $TS" \ + -H "X-Client-Id: mock_carrier" \ + -H "X-Signature: $SIG" \ + -d "$BODY" +``` + +> 重要:签名计算必须使用**原始请求体文本**(raw body)。接收器也会使用 raw body 进行验签;不要用 JSON 对象 stringify 后的字符串替代。 + +健康检查: +- `GET http://localhost:7201/health`(端口以 `PORT` 为准) + +常见问题排查: +- 返回 `{ ok:false, message:'waybill not found' }`:说明 webhook 已收到请求,但在 `platform_express_waybills` 中找不到 `tracking_no`(或 `order_no`)匹配的记录。 +- 返回 `502 supabase unauthorized (check SUPA_KEY/SUPA_URL)`:说明当前 `SUPA_KEY` / `SUPA_URL` 无法通过 Supabase REST 鉴权(常见于 key 填错、已失效、URL 不对)。请换成 Supabase 控制台中的真实 `service_role` key,并重启接收器。 + +Windows 下保持“持续监听”(后台运行): +> 只要 Node 进程还在,webhook 就会持续监听;如果你关闭终端/窗口或按 `Ctrl+C`,进程结束就不会再监听。 + +```powershell +# 后台启动并把日志写到文件(推荐) +Start-Process node -ArgumentList 'pages/mall/delivery/webhook-server/webhook-receiver.js' \ + -WorkingDirectory (Get-Location) \ + -RedirectStandardOutput '.\webhook-receiver.log' \ + -RedirectStandardError '.\webhook-receiver.err.log' \ + -PassThru + +# 查看是否在监听(把 7201 换成你的 PORT) +netstat -ano | findstr :7201 + +# 查看健康检查 +Invoke-RestMethod -Uri 'http://localhost:7201/health' -Method GET | ConvertTo-Json +``` + +停止服务(按 PID 结束进程): +```powershell +# 先用 netstat 找到 LISTENING 的 PID,然后结束它 +Stop-Process -Id +``` + +依赖说明: +- 建议使用 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) + +作者:自动生成(可手动调整) diff --git a/pages/mall/delivery/webhook-server/test-send.js b/pages/mall/delivery/webhook-server/test-send.js new file mode 100644 index 00000000..fbd895b6 --- /dev/null +++ b/pages/mall/delivery/webhook-server/test-send.js @@ -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() diff --git a/pages/mall/delivery/webhook-server/webhook-receiver.js b/pages/mall/delivery/webhook-server/webhook-receiver.js new file mode 100644 index 00000000..a86ae6fe --- /dev/null +++ b/pages/mall/delivery/webhook-server/webhook-receiver.js @@ -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) }) diff --git a/pages/mall/delivery/webhook-server/webhook.config.json b/pages/mall/delivery/webhook-server/webhook.config.json new file mode 100644 index 00000000..dc1a5098 --- /dev/null +++ b/pages/mall/delivery/webhook-server/webhook.config.json @@ -0,0 +1,6 @@ +{ + "SUPA_URL": "http://192.168.1.62:18000", + "SUPA_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzM2MjExNDMsImV4cCI6MTkzMTMwMTE0M30.zahVzrUCUkB436SHYyM2muGL3Lg_aocJJgWv1t6PpKg", + "WEBHOOK_SECRET": "", + "PORT": "7201" +} diff --git a/pages/mall/delivery/webhook-server/webhook.config.json.example b/pages/mall/delivery/webhook-server/webhook.config.json.example new file mode 100644 index 00000000..62a36b21 --- /dev/null +++ b/pages/mall/delivery/webhook-server/webhook.config.json.example @@ -0,0 +1,6 @@ +{ + "SUPA_URL": "http://192.168.1.62:18000", + "SUPA_KEY": "PASTE_YOUR_SERVICE_ROLE_KEY_HERE", + "WEBHOOK_SECRET": "", + "PORT": "7201" +} diff --git a/pages/user/login.uvue b/pages/user/login.uvue index a0ffd7e6..97e9b6e8 100644 --- a/pages/user/login.uvue +++ b/pages/user/login.uvue @@ -141,8 +141,10 @@