同步修改页面逻辑
This commit is contained in:
152
BACKEND_MIGRATION_PLAN.md
Normal file
152
BACKEND_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 后端收敛与防护设计(最小可落地方案)
|
||||||
|
|
||||||
|
说明:本文档针对当前仓库中前端直接写 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;
|
||||||
|
|
||||||
|
-- 执行原子更新:仅当 task 可被领取(status=1 且 driver_id IS NULL)
|
||||||
|
WITH u AS (
|
||||||
|
UPDATE ml_delivery_tasks
|
||||||
|
SET driver_id = p_driver_uuid, status = 2, updated_at = now()
|
||||||
|
WHERE id = p_task_uuid AND status = 1 AND driver_id IS NULL
|
||||||
|
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_delivery_tasks', 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_delivery_tasks`、`ml_orders`):
|
||||||
|
- 禁止直接由匿名或普通前端更新关键列(例如 driver_id、status);只允许 `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_delivery_tasks`、`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` 选项,或告诉我需要调整的文档内容/格式。
|
||||||
@@ -65,7 +65,8 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
const data = await mockService.getMockOrders()
|
// 使用不带用户过滤的接口,加载所有运单,便于模拟推送测试
|
||||||
|
const data = await mockService.getAllOrders()
|
||||||
orders.value = data
|
orders.value = data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
<script uts>
|
<script uts>
|
||||||
import { mockService, MockOrder } from './mock-service.uts'
|
import { mockService, MockOrder } from './mock-service.uts'
|
||||||
|
import { getCurrentUser } from '@/utils/store.uts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -73,6 +74,13 @@
|
|||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
},
|
},
|
||||||
async loadData() {
|
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()
|
const data = await mockService.getMockOrders()
|
||||||
this.orders = [...data]
|
this.orders = [...data]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
<script uts>
|
<script uts>
|
||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import { mockService, MockOrder, LogisticsConstants } from './mock-service.uts'
|
import { mockService, MockOrder, LogisticsConstants } from './mock-service.uts'
|
||||||
|
import { getCurrentUser, getCurrentUserId } from '@/utils/store.uts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -125,8 +126,21 @@
|
|||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
},
|
},
|
||||||
async loadData() {
|
async loadData() {
|
||||||
const data = await mockService.getMockOrders()
|
// Ensure user mapping (auth -> ak_users) is ready before querying
|
||||||
this.orders = [...data]
|
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) {
|
setFilter(filter: string) {
|
||||||
this.currentFilter = filter
|
this.currentFilter = filter
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
|
import { getCurrentUserId } from '@/utils/store.uts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配送模块数据服务 (Express Service)
|
* 配送模块数据服务 (Express Service)
|
||||||
@@ -9,6 +10,8 @@ import supa from '@/components/supadb/aksupainstance.uts'
|
|||||||
export interface MockOrder {
|
export interface MockOrder {
|
||||||
id?: string
|
id?: string
|
||||||
order_no: string
|
order_no: string
|
||||||
|
user_id?: string
|
||||||
|
merchant_id?: string
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
receiver_name: string
|
receiver_name: string
|
||||||
@@ -104,19 +107,81 @@ class MockService {
|
|||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async getMockOrders(): Promise<MockOrder[]> {
|
async getMockOrders(): Promise<MockOrder[]> {
|
||||||
const { data, error } = await supa.from('platform_express_waybills')
|
const userId = getCurrentUserId()
|
||||||
.select('*,ml_orders(*)')
|
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 })
|
.order('created_at', { ascending: false })
|
||||||
.execute()
|
|
||||||
|
|
||||||
if (error != null) {
|
if (userId != '' && userId != 'admin') {
|
||||||
console.error('Fetch orders error:', error)
|
console.log('DEBUG: filtering by ml_orders.user_id =', userId)
|
||||||
return []
|
query.eq('ml_orders.user_id', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = data as Array<UTSJSONObject>
|
const { data: d1, error: e1 } = await query.execute()
|
||||||
return list.map((item: UTSJSONObject): MockOrder => {
|
|
||||||
// 兼容 Supabase 关联查询返回对象或数组的情况
|
// 如果请求本身报错,先记录并尝试降级策略
|
||||||
|
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
|
let ml: UTSJSONObject | null = null
|
||||||
const mlData = item['ml_orders']
|
const mlData = item['ml_orders']
|
||||||
if (mlData != null) {
|
if (mlData != null) {
|
||||||
@@ -138,6 +203,7 @@ class MockService {
|
|||||||
amount: (ml != null ? ml['amount'] as string : '0.00'),
|
amount: (ml != null ? ml['amount'] as string : '0.00'),
|
||||||
carrier: item['carrier'] as string,
|
carrier: item['carrier'] as string,
|
||||||
tracking_no: item['tracking_no'] 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),
|
last_synced_at: this.formatDBTime(item['last_synced_at'] as string),
|
||||||
current_status_text: item['current_status_text'] as string
|
current_status_text: item['current_status_text'] as string
|
||||||
} as MockOrder
|
} as MockOrder
|
||||||
@@ -210,6 +276,24 @@ class MockService {
|
|||||||
return []
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模拟各种物流场景 (生成生产测试数据)
|
* 模拟各种物流场景 (生成生产测试数据)
|
||||||
*/
|
*/
|
||||||
|
|||||||
84
pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md
Normal file
84
pages/mall/delivery/webhook-server/PARTNER_ONBOARDING.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
**概述**
|
||||||
|
- **目标**: 让快递合作方(例如韵达)能安全、可靠地向系统的 `/webhook/express/status` 发送回调,用于运单状态同步与推送触发。
|
||||||
|
- **范围**: 包含鉴权/验签约定、幂等策略、测试样例、部署与运维建议。
|
||||||
|
|
||||||
|
**接入概览**
|
||||||
|
- **Endpoint**: `https://<your-domain>/webhook/express/status`
|
||||||
|
- **协议**: HTTPS(TLS 必需)
|
||||||
|
- **数据格式**: `application/json`,body 为第三方原始回调 JSON。
|
||||||
|
|
||||||
|
**鉴权与验签**
|
||||||
|
- **方式**: 每个合作方分配一份 HMAC secret(UTF-8 文本),服务端按 `client_id` 查秘钥(建议把 `client_id` 放在请求头 `X-Client-Id`)。
|
||||||
|
- **签名头**: `X-Timestamp`(UTC 秒)与 `X-Signature`(hex 小写)。
|
||||||
|
- **签名算法**: HMAC-SHA256(secret, rawBodyText + timestamp) → hex。
|
||||||
|
- **时窗**: 接收端按 ±300 秒(可配置)判定时间窗,超时拒绝(防重放)。
|
||||||
|
- **严格模式**: `WEBHOOK_REJECT_INVALID_SIGNATURE=true` 时签名不合将直接 401(上线初期可先设 false,观测后切为 true)。
|
||||||
|
|
||||||
|
**幂等与去重**
|
||||||
|
- **推荐字段**: 合作方可在 body 中提供 `dedupe_key`(优先使用);若无,服务端基于稳定字段计算哈希(建议用 `tracking_no|carrier|event_time|event_code` 组合)。
|
||||||
|
- **数据库约束**: `platform_express_tracking_events` 应有唯一约束 `(waybill_id, dedupe_key)`,重复回调不再写入事件表。
|
||||||
|
- **返回语义**: 对重复回调可返回 200 并在 body 标注 `duplicate: true`,以便合作方不再重试。
|
||||||
|
|
||||||
|
**请求/响应与重试策略**
|
||||||
|
- **接收端快速 ACK**: 推荐返回 HTTP 202 Accepted(或 200)表示“已入队/接收”,不代表处理完成。
|
||||||
|
- **错误返回**: 4xx 表示请求问题(签名/格式),5xx 表示服务端异常(合作方可按重试策略重试)。
|
||||||
|
- **建议重试规则**(对方遵守): 3–5 次,指数退避(例如 1m, 2m, 4m, ...),总重试时长不超过 1 小时。
|
||||||
|
|
||||||
|
**示例:签名与发送(bash)**
|
||||||
|
```bash
|
||||||
|
TS=$(date -u +%s)
|
||||||
|
BODY='{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
|
||||||
|
SECRET='YOUR_PARTNER_SECRET'
|
||||||
|
SIG=$(printf '%s%s' "$BODY" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
|
||||||
|
curl -v -X POST https://your-domain/webhook/express/status \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Timestamp: $TS" \
|
||||||
|
-H "X-Signature: $SIG" \
|
||||||
|
-H "X-Client-Id: partner-yd" \
|
||||||
|
-d "$BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:PowerShell(Windows)**
|
||||||
|
```powershell
|
||||||
|
$ts = [int][double]::Parse((Get-Date).ToUniversalTime().Subtract([datetime]'1970-01-01').TotalSeconds)
|
||||||
|
$body = '{"tracking_no":"YT123","event_code":"DELIVERED","event_time":"2026-03-12T10:00:00Z"}'
|
||||||
|
$secret = 'YOUR_PARTNER_SECRET'
|
||||||
|
$hmac = New-Object System.Security.Cryptography.HMACSHA256([Text.Encoding]::UTF8.GetBytes($secret))
|
||||||
|
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($body + $ts))
|
||||||
|
$sig = ($hash | ForEach-Object { $_.ToString("x2") }) -join ''
|
||||||
|
Invoke-RestMethod -Uri 'https://your-domain/webhook/express/status' -Method Post -Body $body -ContentType 'application/json' -Headers @{ 'X-Timestamp'=$ts; 'X-Signature'=$sig; 'X-Client-Id'='partner-yd' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试与沙箱**
|
||||||
|
- **临时隧道**: 联调期可使用 `ngrok` 或 `cloudflared` 将本地服务映射到公网供合作方发送测试回调。
|
||||||
|
- **测试密钥**: 为每个合作方发放 `test_secret` 与 `prod_secret`,并在系统中分离管理。
|
||||||
|
- **回放工具**: 提供一个简单 `test-send.js`(仓库已有)并在 README 中示例化如何运行。
|
||||||
|
|
||||||
|
**部署与网关建议**
|
||||||
|
- **域名与 TLS**: 使用 `webhook.<your-domain>`,证书推荐用 Let's Encrypt 自动续期。
|
||||||
|
- **反向代理 / 网关**: 推荐放在 Nginx / Kong / Cloud Load Balancer 前做:
|
||||||
|
- TLS 终端、IP 白名单、速率限制(rate-limiting)、日志落盘与审计。
|
||||||
|
- 如使用 Kong,可在网关做 `key-auth` 或 IP 白名单做第一道防护。
|
||||||
|
- **WAF/防护**: 对外公开时开启基础 WAF(过滤常见攻击)与 DDOS 保护。
|
||||||
|
|
||||||
|
**监控与告警**
|
||||||
|
- **关键 Metric**: 接收量、验签失败率、重复率(duplicate percent)、处理延时、no-targets 率。
|
||||||
|
- **告警阈值**: 验签失败率 >1% 或 no-targets 突增 触发 PagerDuty/邮件告警。
|
||||||
|
|
||||||
|
**上线流程(最小安全步骤)**
|
||||||
|
1. 在测试环境配置 partner test_secret 与 `X-Client-Id`,提供示例脚本。
|
||||||
|
2. 使用 `ngrok` 联调,通过 2–3 次真实回调验证 `dedupe_key`、waybill 匹配和入队。
|
||||||
|
3. 把 partner 的 prod_secret 写入生产密钥存储(避免明文在 repo)。
|
||||||
|
4. 在生产网关启用 IP 白名单或 key-auth,并在低流量窗口把 `WEBHOOK_REJECT_INVALID_SIGNATURE` 改为 `true`。
|
||||||
|
|
||||||
|
**常见问题与排查**
|
||||||
|
- **验签失败**: 检查是否对方用了 stringify 后的 body 或时间戳单位不一致(秒 vs 毫秒);建议双方按示例脚本校验。
|
||||||
|
- **重复回调仍写入**: 检查 `dedupe_key` 的字段组合与 DB 唯一约束 `(waybill_id,dedupe_key)` 是否生效。
|
||||||
|
- **no-targets**: 确保 `push_devices` 已按 `merchant_id` 或 `user_id` 正确写入(见 `GET /api/v1/push/devices` 验证)。
|
||||||
|
|
||||||
|
**联系人与上游联调清单**
|
||||||
|
- **提供方需给出**: 回调样例(真实 JSON)、回调频率预估、回调 IP 段、是否支持 `dedupe_key` 字段、联系人邮箱/电话。
|
||||||
|
- **我方需要提供**: `test_secret`、测试域名、示例脚本、接入说明(本文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
作者:自动生成;可根据合作方反馈补充示例与节流策略。
|
||||||
@@ -70,18 +70,45 @@ function stableEventDedupeKey({ tracking_no, carrier, status_code, event_time, e
|
|||||||
|
|
||||||
async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
|
async function upsertRaw(payload, tracking_no, carrier, signature_valid) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const received_at = new Date().toISOString()
|
||||||
|
|
||||||
|
const minimal = {
|
||||||
carrier: carrier || null,
|
carrier: carrier || null,
|
||||||
tracking_no: tracking_no || null,
|
tracking_no: tracking_no || null,
|
||||||
body: payload,
|
body: payload,
|
||||||
received_at: new Date().toISOString(),
|
received_at,
|
||||||
signature_valid: signature_valid
|
signature_valid: signature_valid
|
||||||
}
|
}
|
||||||
const resp = await supaFetch('platform_express_event_raw', {
|
|
||||||
method: 'POST',
|
// Prefer inserting richer metadata when the upgraded schema is present.
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// If the database schema is older (missing columns), fall back to minimal.
|
||||||
body: JSON.stringify(body)
|
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
|
return resp
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('upsertRaw error', e)
|
console.warn('upsertRaw error', e)
|
||||||
@@ -178,13 +205,25 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express()
|
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) => {
|
app.post('/webhook/express/status', async (req, res) => {
|
||||||
const ts = req.headers['x-timestamp'] || req.headers['X-TIMESTAMP'] || ''
|
const ts = req.headers['x-timestamp'] || ''
|
||||||
const sig = req.headers['x-signature'] || req.headers['X-SIGNATURE'] || ''
|
const sig = req.headers['x-signature'] || ''
|
||||||
const cid = req.headers['x-client-id'] || req.headers['X-CLIENT-ID'] || ''
|
const cid = req.headers['x-client-id'] || ''
|
||||||
const bodyText = JSON.stringify(req.body || {})
|
const bodyText = (req.rawBody && typeof req.rawBody === 'string' && req.rawBody.length > 0)
|
||||||
|
? req.rawBody
|
||||||
|
: JSON.stringify(req.body || {})
|
||||||
|
|
||||||
let sigValid = true
|
let sigValid = true
|
||||||
if (WEBHOOK_SECRET) {
|
if (WEBHOOK_SECRET) {
|
||||||
@@ -192,13 +231,32 @@ async function start() {
|
|||||||
sigValid = calc === String(sig)
|
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)
|
// persist raw (best-effort)
|
||||||
await upsertRaw(
|
const tracking_no_raw = req.body && (req.body.mailNo || req.body.tracking_no)
|
||||||
req.body || {},
|
const carrier_raw = req.body && (req.body.carrier || req.body.company)
|
||||||
req.body && (req.body.mailNo || req.body.tracking_no),
|
const dedupe_key_raw = (() => {
|
||||||
req.body && (req.body.carrier || req.body.company),
|
try {
|
||||||
sigValid
|
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
|
// find waybill
|
||||||
const tracking_no = req.body && (req.body.mailNo || req.body.tracking_no)
|
const tracking_no = req.body && (req.body.mailNo || req.body.tracking_no)
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ async function getDevicesFromSupabase({ user_id, active } = {}) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMerchantDevicesFromSupabase({ merchant_id, active } = {}) {
|
||||||
|
let q = 'push_devices'
|
||||||
|
const params = []
|
||||||
|
if (merchant_id) params.push(`merchant_id=eq.${encodeURIComponent(merchant_id)}`)
|
||||||
|
if (active != null) params.push(`is_active=eq.${encodeURIComponent(active ? 'true' : 'false')}`)
|
||||||
|
const suffix = params.length ? `?${params.join('&')}` : ''
|
||||||
|
const resp = await supaFetch(`${q}${suffix}`, { method: 'GET' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text()
|
||||||
|
throw new Error(`supabase get merchant devices failed ${resp.status} ${txt}`)
|
||||||
|
}
|
||||||
|
const data = await resp.json()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
await ensureDataDir()
|
await ensureDataDir()
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -350,18 +365,31 @@ async function start() {
|
|||||||
|
|
||||||
// 注册或更新设备
|
// 注册或更新设备
|
||||||
app.post('/api/v1/push/register', async (req, res) => {
|
app.post('/api/v1/push/register', async (req, res) => {
|
||||||
const { cid, user_id, platform } = req.body || {}
|
const { cid, user_id, merchant_id, platform, appid, registration_source } = req.body || {}
|
||||||
if (!cid) return res.status(400).json({ error: 'cid required' })
|
if (!cid) return res.status(400).json({ error: 'cid required' })
|
||||||
const devices = await readDevices()
|
const devices = await readDevices()
|
||||||
let found = devices.find(d => d.cid === cid)
|
let found = devices.find(d => d.cid === cid)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
if (found) {
|
if (found) {
|
||||||
found.user_id = user_id ?? found.user_id
|
found.user_id = user_id ?? found.user_id
|
||||||
|
found.merchant_id = merchant_id ?? found.merchant_id
|
||||||
found.platform = platform ?? found.platform
|
found.platform = platform ?? found.platform
|
||||||
|
found.appid = appid ?? found.appid
|
||||||
|
found.registration_source = registration_source ?? found.registration_source
|
||||||
found.updated_at = now
|
found.updated_at = now
|
||||||
found.active = true
|
found.active = true
|
||||||
} else {
|
} else {
|
||||||
found = { cid, user_id: user_id ?? null, platform: platform ?? null, created_at: now, updated_at: now, active: true }
|
found = {
|
||||||
|
cid,
|
||||||
|
user_id: user_id ?? null,
|
||||||
|
merchant_id: merchant_id ?? null,
|
||||||
|
platform: platform ?? null,
|
||||||
|
appid: appid ?? 'default',
|
||||||
|
registration_source: registration_source ?? null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
active: true
|
||||||
|
}
|
||||||
devices.push(found)
|
devices.push(found)
|
||||||
}
|
}
|
||||||
await writeDevices(devices)
|
await writeDevices(devices)
|
||||||
@@ -372,12 +400,14 @@ async function start() {
|
|||||||
const body = {
|
const body = {
|
||||||
cid: found.cid,
|
cid: found.cid,
|
||||||
user_id: found.user_id,
|
user_id: found.user_id,
|
||||||
|
merchant_id: found.merchant_id,
|
||||||
platform: found.platform,
|
platform: found.platform,
|
||||||
appid: found.appid || 'default',
|
appid: found.appid || 'default',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
last_seen_at: found.updated_at
|
last_seen_at: found.updated_at
|
||||||
}
|
}
|
||||||
const resp = await supaFetch(`push_devices?on_conflict=cid`, {
|
if (found.registration_source) body.registration_source = found.registration_source
|
||||||
|
const resp = await supaFetch(`push_devices?on_conflict=appid,cid`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
@@ -392,18 +422,20 @@ async function start() {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ ok: true, cid })
|
return res.json({ ok: true, cid, user_id: found.user_id || null, merchant_id: found.merchant_id || null })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 注销设备(可选移除或置为 inactive)
|
// 注销设备(可选移除或置为 inactive)
|
||||||
app.post('/api/v1/push/unregister', async (req, res) => {
|
app.post('/api/v1/push/unregister', async (req, res) => {
|
||||||
const { cid, user_id } = req.body || {}
|
const { cid, user_id, merchant_id } = req.body || {}
|
||||||
if (!cid && !user_id) return res.status(400).json({ error: 'cid or user_id required' })
|
if (!cid && !user_id && !merchant_id) return res.status(400).json({ error: 'cid or user_id or merchant_id required' })
|
||||||
let devices = await readDevices()
|
let devices = await readDevices()
|
||||||
if (cid) {
|
if (cid) {
|
||||||
devices = devices.map(d => d.cid === cid ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
devices = devices.map(d => d.cid === cid ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||||
} else if (user_id) {
|
} else if (user_id) {
|
||||||
devices = devices.map(d => d.user_id === user_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
devices = devices.map(d => d.user_id === user_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||||
|
} else if (merchant_id) {
|
||||||
|
devices = devices.map(d => d.merchant_id === merchant_id ? Object.assign({}, d, { active: false, updated_at: new Date().toISOString() }) : d)
|
||||||
}
|
}
|
||||||
await writeDevices(devices)
|
await writeDevices(devices)
|
||||||
// 同步到 Supabase(如果可用)
|
// 同步到 Supabase(如果可用)
|
||||||
@@ -423,6 +455,12 @@ async function start() {
|
|||||||
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
body: JSON.stringify({ is_active: false, updated_at: now })
|
body: JSON.stringify({ is_active: false, updated_at: now })
|
||||||
})
|
})
|
||||||
|
} else if (merchant_id) {
|
||||||
|
await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(merchant_id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ is_active: false, updated_at: now })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Supabase unregister exception:', e)
|
console.warn('Supabase unregister exception:', e)
|
||||||
@@ -435,14 +473,17 @@ async function start() {
|
|||||||
|
|
||||||
// 列出设备
|
// 列出设备
|
||||||
app.get('/api/v1/push/devices', async (req, res) => {
|
app.get('/api/v1/push/devices', async (req, res) => {
|
||||||
const { user_id, active } = req.query
|
const { user_id, merchant_id, active } = req.query
|
||||||
try {
|
try {
|
||||||
if (SUPA_URL && SUPA_KEY) {
|
if (SUPA_URL && SUPA_KEY) {
|
||||||
const devices = await getDevicesFromSupabase({ user_id, active: active == null ? undefined : (active === 'true') })
|
let devices = []
|
||||||
|
if (merchant_id) devices = await getMerchantDevicesFromSupabase({ merchant_id, active: active == null ? undefined : (active === 'true') })
|
||||||
|
else devices = await getDevicesFromSupabase({ user_id, active: active == null ? undefined : (active === 'true') })
|
||||||
return res.json({ ok: true, total: devices.length, data: devices })
|
return res.json({ ok: true, total: devices.length, data: devices })
|
||||||
}
|
}
|
||||||
let devices = await readDevices()
|
let devices = await readDevices()
|
||||||
if (user_id) devices = devices.filter(d => String(d.user_id) === String(user_id))
|
if (user_id) devices = devices.filter(d => String(d.user_id) === String(user_id))
|
||||||
|
if (merchant_id) devices = devices.filter(d => String(d.merchant_id) === String(merchant_id))
|
||||||
if (active != null) devices = devices.filter(d => String(!!d.active) === String(active === 'true'))
|
if (active != null) devices = devices.filter(d => String(!!d.active) === String(active === 'true'))
|
||||||
res.json({ ok: true, total: devices.length, data: devices })
|
res.json({ ok: true, total: devices.length, data: devices })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ export async function getCurrentUser() : Promise<UserProfile | null> {
|
|||||||
state.userProfile = { username: '', email: '' }
|
state.userProfile = { username: '', email: '' }
|
||||||
state.isLoggedIn = false // 未登录
|
state.isLoggedIn = false // 未登录
|
||||||
return null
|
return null
|
||||||
} // 查询 ak_users 表补全 profile
|
} // 查询 ak_users 表补全 profile,通过 auth_id (session 中的 user.id) 进行匹配
|
||||||
const res = await supa.from('ak_users').select('*', {}).eq('id', userId).execute()
|
const res = await supa.from('ak_users').select('*', {}).eq('auth_id', userId).execute()
|
||||||
console.log(res)
|
console.log('Profile Load Result:', res)
|
||||||
if (res.status >= 200 && res.status < 300 && (res.data != null)) {
|
if (res.status >= 200 && res.status < 300 && (res.data != null)) {
|
||||||
let user : UTSJSONObject | null = null;
|
let user : UTSJSONObject | null = null;
|
||||||
const data = res.data as any;
|
const data = res.data as any;
|
||||||
@@ -98,9 +98,43 @@ export async function getCurrentUser() : Promise<UserProfile | null> {
|
|||||||
}
|
}
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
user = data as UTSJSONObject;
|
user = data as UTSJSONObject;
|
||||||
} console.log(user)
|
}
|
||||||
|
|
||||||
|
if (user != null) {
|
||||||
|
const profile : UserProfile = {
|
||||||
|
id: user.getString('id'),
|
||||||
|
username: user.getString('username') ?? "",
|
||||||
|
email: user.getString('email') ?? "",
|
||||||
|
role: user.getString('role'),
|
||||||
|
avatar_url: user.getString('avatar_url')
|
||||||
|
} as UserProfile
|
||||||
|
state.userProfile = profile
|
||||||
|
state.isLoggedIn = true
|
||||||
|
|
||||||
|
// 关键点:将 ak_users 表中的 UUID 存入本地存储,确保过滤时使用的是业务表的 ID
|
||||||
|
if (profile.id != null) {
|
||||||
|
uni.setStorageSync('user_id', profile.id)
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果按 auth_id 没查到,尝试按原逻辑 (id = userId) 查一次作为兼容
|
||||||
|
const resFallback = await supa.from('ak_users').select('*', {}).eq('id', userId).execute()
|
||||||
|
if (resFallback.status >= 200 && resFallback.status < 300 && (resFallback.data != null)) {
|
||||||
|
let user : UTSJSONObject | null = null;
|
||||||
|
const data = resFallback.data as any;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length > 0) {
|
||||||
|
user = data[0] as UTSJSONObject;
|
||||||
|
}
|
||||||
|
} else if (data != null) {
|
||||||
|
user = data as UTSJSONObject;
|
||||||
|
}
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
console.log('用户资料为空,尝试创建基础资料...') // 如果用户资料为空,尝试创建基础用户资料
|
console.log('用户资料为空,尝试创建基础资料...')
|
||||||
|
// 如果用户资料为空,尝试创建基础用户资料
|
||||||
const sessionUser = sessionInfo.user
|
const sessionUser = sessionInfo.user
|
||||||
if (sessionUser != null) {
|
if (sessionUser != null) {
|
||||||
const createdProfile = await ensureUserProfile(sessionUser)
|
const createdProfile = await ensureUserProfile(sessionUser)
|
||||||
@@ -121,8 +155,7 @@ export async function getCurrentUser() : Promise<UserProfile | null> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(user)
|
|
||||||
// 直接用 getString/getNumber,无需兜底属性
|
|
||||||
const profile : UserProfile = {
|
const profile : UserProfile = {
|
||||||
id: user.getString('id'),
|
id: user.getString('id'),
|
||||||
username: user.getString('username') ?? "",
|
username: user.getString('username') ?? "",
|
||||||
@@ -141,6 +174,9 @@ export async function getCurrentUser() : Promise<UserProfile | null> {
|
|||||||
}
|
}
|
||||||
state.userProfile = profile
|
state.userProfile = profile
|
||||||
state.isLoggedIn = true // 登录成功
|
state.isLoggedIn = true // 登录成功
|
||||||
|
if (profile.id != null) {
|
||||||
|
uni.setStorageSync('user_id', profile.id)
|
||||||
|
}
|
||||||
return profile
|
return profile
|
||||||
} else {
|
} else {
|
||||||
state.userProfile = { username: '', email: '' }
|
state.userProfile = { username: '', email: '' }
|
||||||
|
|||||||
Reference in New Issue
Block a user