同步修改页面逻辑

This commit is contained in:
not-like-juvenile
2026-03-12 18:05:32 +08:00
parent 4acbb8ced5
commit e70211f1d2
9 changed files with 522 additions and 44 deletions

View File

@@ -65,7 +65,8 @@
})
async function loadOrders() {
const data = await mockService.getMockOrders()
// 使用不带用户过滤的接口,加载所有运单,便于模拟推送测试
const data = await mockService.getAllOrders()
orders.value = data
}

View File

@@ -45,6 +45,7 @@
<script uts>
import { mockService, MockOrder } from './mock-service.uts'
import { getCurrentUser } from '@/utils/store.uts'
export default {
data() {
@@ -73,6 +74,13 @@
uni.navigateBack()
},
async loadData() {
// Ensure user mapping is ready before querying orders
try {
await getCurrentUser()
} catch (e) {
console.warn('getCurrentUser failed:', e)
}
const data = await mockService.getMockOrders()
this.orders = [...data]
},

View File

@@ -84,6 +84,7 @@
<script uts>
import supa from '@/components/supadb/aksupainstance.uts'
import { mockService, MockOrder, LogisticsConstants } from './mock-service.uts'
import { getCurrentUser, getCurrentUserId } from '@/utils/store.uts'
export default {
data() {
@@ -125,8 +126,21 @@
uni.navigateBack()
},
async loadData() {
const data = await mockService.getMockOrders()
this.orders = [...data]
// Ensure user mapping (auth -> ak_users) is ready before querying
try {
await getCurrentUser()
} catch (e) {
console.warn('getCurrentUser failed:', e)
}
// 拉取运单并按 merchant_id 过滤
const data = await mockService.getMockOrders()
const curId = getCurrentUserId()
if (curId && curId !== '' && curId !== 'admin') {
this.orders = [...data].filter((o: any) => (o.merchant_id === curId))
} else {
this.orders = [...data]
}
},
setFilter(filter: string) {
this.currentFilter = filter

View File

@@ -1,4 +1,5 @@
import supa from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId } from '@/utils/store.uts'
/**
* 配送模块数据服务 (Express Service)
@@ -9,6 +10,8 @@ import supa from '@/components/supadb/aksupainstance.uts'
export interface MockOrder {
id?: string
order_no: string
user_id?: string
merchant_id?: string
status: string
created_at: string
receiver_name: string
@@ -104,19 +107,81 @@ class MockService {
constructor() {}
async getMockOrders(): Promise<MockOrder[]> {
const { data, error } = await supa.from('platform_express_waybills')
.select('*,ml_orders(*)')
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 })
.execute()
if (error != null) {
console.error('Fetch orders error:', error)
return []
if (userId != '' && userId != 'admin') {
console.log('DEBUG: filtering by ml_orders.user_id =', userId)
query.eq('ml_orders.user_id', userId)
}
const list = data as Array<UTSJSONObject>
return list.map((item: UTSJSONObject): MockOrder => {
// 兼容 Supabase 关联查询返回对象或数组的情况
const { data: d1, error: e1 } = await query.execute()
// 如果请求本身报错,先记录并尝试降级策略
if (e1 != null) {
console.error('DEBUG: primary query error =', e1)
}
// 规范化返回的数据为数组Supabase 客户端在某些情况下可能返回包装对象)
let resultArray: Array<UTSJSONObject> | null = null
if (Array.isArray(d1)) {
resultArray = d1 as Array<UTSJSONObject>
} else if (d1 != null && (d1 as any).data && Array.isArray((d1 as any).data)) {
resultArray = (d1 as any).data as Array<UTSJSONObject>
}
// 方案 2保底如果方案 1 没查到数据,可能是因为某些运单没有关联订单,或者 !inner 过滤太严格
if (resultArray == null || resultArray.length === 0) {
console.log('DEBUG: first query empty or error, trying fallback...')
const query2 = supa.from('platform_express_waybills')
.select('*, ml_orders(*)')
.order('created_at', { ascending: false })
const { data: d2, error: e2 } = await query2.execute()
if (e2 != null) {
console.error('DEBUG: fallback query error =', e2)
}
const allData = Array.isArray(d2) ? (d2 as Array<UTSJSONObject>) : ((d2 && (d2 as any).data && Array.isArray((d2 as any).data)) ? (d2 as any).data : null)
if (allData != null) {
// 在内存中过滤归属于当前用户的订单(用于数据还没同步完全的情况)
const filtered = allData.filter((item: UTSJSONObject): boolean => {
if (userId == '' || userId == 'admin') return true
let ml: UTSJSONObject | null = null
const mlData = item['ml_orders']
if (mlData instanceof Array && (mlData as Array<any>).length > 0) {
ml = (mlData as Array<UTSJSONObject>)[0]
} else if (!(mlData instanceof Array)) {
ml = mlData as UTSJSONObject
}
try {
return ml != null && (ml as any).user_id == userId
} catch (err) {
return false
}
})
console.log('DEBUG: filtered in memory length =', filtered.length)
return this.mapDataToOrders(filtered)
}
}
console.log('DEBUG: query result data length =', resultArray ? resultArray.length : 0)
return this.mapDataToOrders(resultArray)
}
// 抽离数据映射逻辑
private mapDataToOrders(data: Array<UTSJSONObject> | null): MockOrder[] {
if (data == null) return []
return data.map((item: UTSJSONObject): MockOrder => {
let ml: UTSJSONObject | null = null
const mlData = item['ml_orders']
if (mlData != null) {
@@ -138,6 +203,7 @@ class MockService {
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
@@ -210,6 +276,24 @@ class MockService {
return []
}
/**
* 管理/调试用:获取所有运单(不做用户过滤)
*/
async getAllOrders(): Promise<MockOrder[]> {
const { data, error } = await supa.from('platform_express_waybills')
.select('*, ml_orders(*)')
.order('created_at', { ascending: false })
.execute()
if (error != null) {
console.error('Fetch all orders error:', error)
return []
}
const list = data as Array<UTSJSONObject>
return this.mapDataToOrders(list)
}
/**
* 模拟各种物流场景 (生成生产测试数据)
*/

View File

@@ -0,0 +1,84 @@
**概述**
- **目标**: 让快递合作方(例如韵达)能安全、可靠地向系统的 `/webhook/express/status` 发送回调,用于运单状态同步与推送触发。
- **范围**: 包含鉴权/验签约定、幂等策略、测试样例、部署与运维建议。
**接入概览**
- **Endpoint**: `https://<your-domain>/webhook/express/status`
- **协议**: HTTPSTLS 必需)
- **数据格式**: `application/json`body 为第三方原始回调 JSON。
**鉴权与验签**
- **方式**: 每个合作方分配一份 HMAC secretUTF-8 文本),服务端按 `client_id` 查秘钥(建议把 `client_id` 放在请求头 `X-Client-Id`)。
- **签名头**: `X-Timestamp`UTC 秒)与 `X-Signature`hex 小写)。
- **签名算法**: HMAC-SHA256(secret, rawBodyText + timestamp) → hex。
- **时窗**: 接收端按 ±300 秒(可配置)判定时间窗,超时拒绝(防重放)。
- **严格模式**: `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 时签名不合将直接 401上线初期可先设 false观测后切为 true
**幂等与去重**
- **推荐字段**: 合作方可在 body 中提供 `dedupe_key`(优先使用);若无,服务端基于稳定字段计算哈希(建议用 `tracking_no|carrier|event_time|event_code` 组合)。
- **数据库约束**: `platform_express_tracking_events` 应有唯一约束 `(waybill_id, dedupe_key)`,重复回调不再写入事件表。
- **返回语义**: 对重复回调可返回 200 并在 body 标注 `duplicate: true`,以便合作方不再重试。
**请求/响应与重试策略**
- **接收端快速 ACK**: 推荐返回 HTTP 202 Accepted或 200表示“已入队/接收”,不代表处理完成。
- **错误返回**: 4xx 表示请求问题(签名/格式5xx 表示服务端异常(合作方可按重试策略重试)。
- **建议重试规则**(对方遵守): 35 次,指数退避(例如 1m, 2m, 4m, ...),总重试时长不超过 1 小时。
**示例签名与发送bash**
```bash
TS=$(date -u +%s)
BODY='{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
SECRET='YOUR_PARTNER_SECRET'
SIG=$(printf '%s%s' "$BODY" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
curl -v -X POST https://your-domain/webhook/express/status \
-H 'Content-Type: application/json' \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-H "X-Client-Id: partner-yd" \
-d "$BODY"
```
**示例PowerShellWindows**
```powershell
$ts = [int][double]::Parse((Get-Date).ToUniversalTime().Subtract([datetime]'1970-01-01').TotalSeconds)
$body = '{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
$secret = 'YOUR_PARTNER_SECRET'
$hmac = New-Object System.Security.Cryptography.HMACSHA256([Text.Encoding]::UTF8.GetBytes($secret))
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($body + $ts))
$sig = ($hash | ForEach-Object { $_.ToString("x2") }) -join ''
Invoke-RestMethod -Uri 'https://your-domain/webhook/express/status' -Method Post -Body $body -ContentType 'application/json' -Headers @{ 'X-Timestamp'=$ts; 'X-Signature'=$sig; 'X-Client-Id'='partner-yd' }
```
**测试与沙箱**
- **临时隧道**: 联调期可使用 `ngrok``cloudflared` 将本地服务映射到公网供合作方发送测试回调。
- **测试密钥**: 为每个合作方发放 `test_secret``prod_secret`,并在系统中分离管理。
- **回放工具**: 提供一个简单 `test-send.js`(仓库已有)并在 README 中示例化如何运行。
**部署与网关建议**
- **域名与 TLS**: 使用 `webhook.<your-domain>`,证书推荐用 Let's Encrypt 自动续期。
- **反向代理 / 网关**: 推荐放在 Nginx / Kong / Cloud Load Balancer 前做:
- TLS 终端、IP 白名单、速率限制rate-limiting、日志落盘与审计。
- 如使用 Kong可在网关做 `key-auth` 或 IP 白名单做第一道防护。
- **WAF/防护**: 对外公开时开启基础 WAF过滤常见攻击与 DDOS 保护。
**监控与告警**
- **关键 Metric**: 接收量、验签失败率、重复率duplicate percent、处理延时、no-targets 率。
- **告警阈值**: 验签失败率 >1% 或 no-targets 突增 触发 PagerDuty/邮件告警。
**上线流程(最小安全步骤)**
1. 在测试环境配置 partner test_secret 与 `X-Client-Id`,提供示例脚本。
2. 使用 `ngrok` 联调,通过 23 次真实回调验证 `dedupe_key`、waybill 匹配和入队。
3. 把 partner 的 prod_secret 写入生产密钥存储(避免明文在 repo
4. 在生产网关启用 IP 白名单或 key-auth并在低流量窗口把 `WEBHOOK_REJECT_INVALID_SIGNATURE` 改为 `true`
**常见问题与排查**
- **验签失败**: 检查是否对方用了 stringify 后的 body 或时间戳单位不一致(秒 vs 毫秒);建议双方按示例脚本校验。
- **重复回调仍写入**: 检查 `dedupe_key` 的字段组合与 DB 唯一约束 `(waybill_id,dedupe_key)` 是否生效。
- **no-targets**: 确保 `push_devices` 已按 `merchant_id``user_id` 正确写入(见 `GET /api/v1/push/devices` 验证)。
**联系人与上游联调清单**
- **提供方需给出**: 回调样例(真实 JSON、回调频率预估、回调 IP 段、是否支持 `dedupe_key` 字段、联系人邮箱/电话。
- **我方需要提供**: `test_secret`、测试域名、示例脚本、接入说明(本文件)。
---
作者:自动生成;可根据合作方反馈补充示例与节流策略。

View File

@@ -70,18 +70,45 @@ function stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, e
async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
try {
const body = {
const received_at = new Date().toISOString()
const minimal = {
carrier: carrier || null,
tracking_no: tracking_no || null,
body: payload,
received_at: new Date().toISOString(),
received_at,
signature_valid: signature_valid
}
const resp = await supaFetch('platform_express_event_raw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
// 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)
@@ -178,13 +205,25 @@ async function start() {
}
const app = express()
app.use(bodyParser.json({ limit: '1mb' }))
// 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'] || req.headers['X-TIMESTAMP'] || ''
const sig = req.headers['x-signature'] || req.headers['X-SIGNATURE'] || ''
const cid = req.headers['x-client-id'] || req.headers['X-CLIENT-ID'] || ''
const bodyText = JSON.stringify(req.body || {})
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) {
@@ -192,13 +231,32 @@ async function start() {
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)
await upsertRaw(
req.body || {},
req.body && (req.body.mailNo || req.body.tracking_no),
req.body && (req.body.carrier || req.body.company),
sigValid
)
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)