feifei 异步通知

This commit is contained in:
yml2213
2026-07-07 17:09:55 +08:00
parent 2471df0086
commit c2c168f18e
6 changed files with 279 additions and 1 deletions
+10 -1
View File
@@ -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]'
}
+1
View File
@@ -4,6 +4,7 @@ declare global {
namespace Express {
interface Request {
adminSession?: AdminSession | null
rawBody?: string
requestId?: string
}
}