feifei 异步通知
This commit is contained in:
+10
-1
@@ -4,6 +4,7 @@ import process from "node:process";
|
||||
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import claimsRouter from "./routes/claims.js";
|
||||
import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js";
|
||||
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
||||
import open91Router from "./routes/open-91.js";
|
||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||
@@ -27,7 +28,14 @@ export function createApp({
|
||||
|
||||
app.use(accessLogMiddleware);
|
||||
app.use(createCorsMiddleware(config));
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "2mb",
|
||||
verify: (req, _res, buffer) => {
|
||||
(req as Request).rawBody = buffer.toString("utf8");
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
@@ -92,6 +100,7 @@ export function createApp({
|
||||
|
||||
app.use("/api/v1/open/91", open91Router);
|
||||
app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter);
|
||||
app.use("/api/v1/open/kuaishou-feifei", kuaishouFeifeiRouter);
|
||||
app.use("/api/v1/claim", claimsRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../types/repository/rows.js'
|
||||
|
||||
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||
const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
|
||||
|
||||
type TaskRuntimeContextRow = {
|
||||
runtime_session_id: string
|
||||
@@ -320,6 +321,40 @@ export async function findTaskByClaimTokenId(claimTokenId: number | string): Pro
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findKuaishouFeifeiTaskByOrder(input: {
|
||||
platformOrderNo?: unknown
|
||||
orderNo?: unknown
|
||||
}): Promise<TaskRow | null> {
|
||||
const platformOrderNo = String(input.platformOrderNo || '').trim()
|
||||
const orderNo = String(input.orderNo || '').trim()
|
||||
const params: unknown[] = [KUAISHOU_FEIFEI_EXECUTOR_KEY]
|
||||
const orderFilters: string[] = []
|
||||
|
||||
if (platformOrderNo) {
|
||||
params.push(platformOrderNo)
|
||||
orderFilters.push(`ft.context_json #>> '{kuaishouFeifei,platformOrderNo}' = $${params.length}`)
|
||||
}
|
||||
|
||||
if (orderNo) {
|
||||
params.push(orderNo)
|
||||
orderFilters.push(`ft.context_json #>> '{kuaishouFeifei,orderNo}' = $${params.length}`)
|
||||
}
|
||||
|
||||
if (orderFilters.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await query<TaskRow>(
|
||||
`${buildTaskSelect()}
|
||||
WHERE ft.executor_key = $1
|
||||
AND (${orderFilters.join(' OR ')})
|
||||
ORDER BY ft.id DESC
|
||||
LIMIT 1`,
|
||||
params,
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listTasks({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
import { handleKuaishouFeifeiNotify } from '../services/platforms/kuaishou-feifei/notify-service.js'
|
||||
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||||
import { sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const notifyRateLimit = createRateLimitMiddleware({
|
||||
scope: 'kuaishouFeifeiNotify',
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
})
|
||||
|
||||
router.post('/notify', notifyRateLimit, async (req, res) => {
|
||||
const requestId = createRequestId('ffn')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logIntegration('[kuaishou-feifei/notify]', '收到 kuaishou-feifei 订单通知', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
ip: req.ip,
|
||||
headers: {
|
||||
'content-type': req.headers['content-type'],
|
||||
'x-app-key': req.headers['x-app-key'],
|
||||
'x-timestamp': req.headers['x-timestamp'],
|
||||
'x-sign': req.headers['x-sign'],
|
||||
},
|
||||
body: req.body,
|
||||
rawBody: req.rawBody,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await handleKuaishouFeifeiNotify({
|
||||
body: req.body,
|
||||
headers: req.headers,
|
||||
rawBody: req.rawBody,
|
||||
})
|
||||
logIntegration('[kuaishou-feifei/notify]', '订单通知处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result,
|
||||
})
|
||||
res.status(200).json({ code: 0 })
|
||||
} catch (error) {
|
||||
logIntegration('[kuaishou-feifei/notify]', '订单通知处理失败', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
}, { level: 'error' })
|
||||
sendRouteError(res, error, 'kuaishou-feifei 通知处理失败', '[kuaishou-feifei/notify]')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { signKuaishouFeifeiPayload } from './http-client.js'
|
||||
import { verifyKuaishouFeifeiNotifySignature } from './notify-service.js'
|
||||
|
||||
test('verifyKuaishouFeifeiNotifySignature 使用原始 body 校验 feifei 通知签名', () => {
|
||||
const input = {
|
||||
appKey: 'mk_app_key',
|
||||
appSecret: 'app-secret',
|
||||
timestamp: '1783411548',
|
||||
rawBody: '{"event":"order.status_changed","order":{"platform_order_no":"DT56bf8f1727a7"}}',
|
||||
}
|
||||
const sign = signKuaishouFeifeiPayload({
|
||||
appKey: input.appKey,
|
||||
appSecret: input.appSecret,
|
||||
timestamp: input.timestamp,
|
||||
body: input.rawBody,
|
||||
})
|
||||
const invalidSign = `${sign.slice(0, -1)}${sign.endsWith('0') ? '1' : '0'}`
|
||||
|
||||
assert.equal(verifyKuaishouFeifeiNotifySignature({ ...input, sign }), true)
|
||||
assert.equal(verifyKuaishouFeifeiNotifySignature({ ...input, sign: invalidSign }), false)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { findKuaishouFeifeiTaskByOrder } from '../../../repositories/task-repo.js'
|
||||
import { syncKuaishouFeifeiTaskStatus } from '../../fulfillment/kuaishou-feifei/index.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { assertKuaishouFeifeiConfig } from './config.js'
|
||||
import { signKuaishouFeifeiPayload } from './http-client.js'
|
||||
import { mapKuaishouFeifeiOrder } from './order-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
type NotifyHeaders = Record<string, string | string[] | undefined>
|
||||
|
||||
export async function handleKuaishouFeifeiNotify(input: {
|
||||
body: unknown
|
||||
headers: NotifyHeaders
|
||||
rawBody?: string | undefined
|
||||
}) {
|
||||
const config = assertKuaishouFeifeiConfig()
|
||||
const rawBody = String(input.rawBody || '')
|
||||
const appKey = normalizeHeader(input.headers['x-app-key'])
|
||||
const timestamp = normalizeHeader(input.headers['x-timestamp'])
|
||||
const sign = normalizeHeader(input.headers['x-sign'])
|
||||
|
||||
if (!rawBody) {
|
||||
throw createHttpError('kuaishou-feifei 通知原始请求体缺失', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_feifei_notify_raw_body_missing',
|
||||
})
|
||||
}
|
||||
|
||||
if (!appKey || appKey !== config.appKey) {
|
||||
throw createHttpError('kuaishou-feifei 通知 App Key 无效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'kuaishou_feifei_notify_app_key_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
if (!timestamp || !sign) {
|
||||
throw createHttpError('kuaishou-feifei 通知签名参数缺失', {
|
||||
statusCode: 401,
|
||||
errorCode: 'kuaishou_feifei_notify_sign_missing',
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyKuaishouFeifeiNotifySignature({
|
||||
appKey,
|
||||
appSecret: config.appSecret,
|
||||
timestamp,
|
||||
rawBody,
|
||||
sign,
|
||||
})) {
|
||||
throw createHttpError('kuaishou-feifei 通知验签失败', {
|
||||
statusCode: 401,
|
||||
errorCode: 'kuaishou_feifei_notify_sign_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const source = isPlainObject(input.body) ? input.body : {}
|
||||
const event = String(source.event || '').trim()
|
||||
const order = mapKuaishouFeifeiOrder(source.order)
|
||||
|
||||
if (!order.platformOrderNo && !order.orderNo) {
|
||||
throw createHttpError('kuaishou-feifei 通知订单号缺失', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_feifei_notify_order_no_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const task = await findKuaishouFeifeiTaskByOrder({
|
||||
platformOrderNo: order.platformOrderNo,
|
||||
orderNo: order.orderNo,
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('kuaishou-feifei 通知未匹配到本地任务', {
|
||||
statusCode: 404,
|
||||
errorCode: 'kuaishou_feifei_notify_task_not_found',
|
||||
context: {
|
||||
platformOrderNo: order.platformOrderNo,
|
||||
orderNo: order.orderNo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_feifei_notify_received',
|
||||
{
|
||||
event,
|
||||
platformOrderNo: order.platformOrderNo,
|
||||
orderNo: order.orderNo,
|
||||
productCode: order.productCode,
|
||||
rechargeStatus: order.rechargeStatus,
|
||||
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||
raw: order.raw,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
const updatedTask = await syncKuaishouFeifeiTaskStatus(task)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event,
|
||||
taskId: task.id,
|
||||
platformOrderNo: order.platformOrderNo,
|
||||
orderNo: order.orderNo,
|
||||
rechargeStatus: order.rechargeStatus,
|
||||
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||
taskStatus: updatedTask.task_status,
|
||||
deliveryStatus: updatedTask.delivery_status,
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyKuaishouFeifeiNotifySignature(input: {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
timestamp: string
|
||||
rawBody: string
|
||||
sign: string
|
||||
}) {
|
||||
const expected = signKuaishouFeifeiPayload({
|
||||
appKey: input.appKey,
|
||||
appSecret: input.appSecret,
|
||||
timestamp: input.timestamp,
|
||||
body: input.rawBody,
|
||||
})
|
||||
|
||||
return timingSafeEqualString(expected, String(input.sign || '').trim().toLowerCase())
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? String(value[0] || '').trim() : String(value || '').trim()
|
||||
}
|
||||
|
||||
function timingSafeEqualString(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left, 'utf8')
|
||||
const rightBuffer = Buffer.from(right, 'utf8')
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
adminSession?: AdminSession | null
|
||||
rawBody?: string
|
||||
requestId?: string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user