feifei 异步通知
This commit is contained in:
+10
-1
@@ -4,6 +4,7 @@ import process from "node:process";
|
|||||||
|
|
||||||
import adminRouter from "./routes/admin.js";
|
import adminRouter from "./routes/admin.js";
|
||||||
import claimsRouter from "./routes/claims.js";
|
import claimsRouter from "./routes/claims.js";
|
||||||
|
import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js";
|
||||||
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
||||||
import open91Router from "./routes/open-91.js";
|
import open91Router from "./routes/open-91.js";
|
||||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||||
@@ -27,7 +28,14 @@ export function createApp({
|
|||||||
|
|
||||||
app.use(accessLogMiddleware);
|
app.use(accessLogMiddleware);
|
||||||
app.use(createCorsMiddleware(config));
|
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.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -92,6 +100,7 @@ export function createApp({
|
|||||||
|
|
||||||
app.use("/api/v1/open/91", open91Router);
|
app.use("/api/v1/open/91", open91Router);
|
||||||
app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter);
|
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/claim", claimsRouter);
|
||||||
app.use("/api/v1/admin", adminRouter);
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
} from '../types/repository/rows.js'
|
} from '../types/repository/rows.js'
|
||||||
|
|
||||||
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||||
|
const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
|
||||||
|
|
||||||
type TaskRuntimeContextRow = {
|
type TaskRuntimeContextRow = {
|
||||||
runtime_session_id: string
|
runtime_session_id: string
|
||||||
@@ -320,6 +321,40 @@ export async function findTaskByClaimTokenId(claimTokenId: number | string): Pro
|
|||||||
return result.rows[0] || null
|
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({
|
export async function listTasks({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
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 {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
adminSession?: AdminSession | null
|
adminSession?: AdminSession | null
|
||||||
|
rawBody?: string
|
||||||
requestId?: string
|
requestId?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user