From c2c168f18e80cda3446d2da844ad2ae626785e11 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 7 Jul 2026 17:09:55 +0800 Subject: [PATCH] =?UTF-8?q?feifei=20=E5=BC=82=E6=AD=A5=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/app.ts | 11 +- apps/backend/src/repositories/task-repo.ts | 35 ++++ apps/backend/src/routes/kuaishou-feifei.ts | 57 +++++++ .../kuaishou-feifei/notify-service.test.ts | 24 +++ .../kuaishou-feifei/notify-service.ts | 152 ++++++++++++++++++ apps/backend/src/types/express.d.ts | 1 + 6 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/routes/kuaishou-feifei.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index d672cba4..a4bd5a77 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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); diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index ac9fd781..f0b01afd 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -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 { + 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( + `${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, diff --git a/apps/backend/src/routes/kuaishou-feifei.ts b/apps/backend/src/routes/kuaishou-feifei.ts new file mode 100644 index 00000000..11008c4d --- /dev/null +++ b/apps/backend/src/routes/kuaishou-feifei.ts @@ -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 diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts new file mode 100644 index 00000000..86cc7793 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts new file mode 100644 index 00000000..5d4035e1 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts @@ -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 +type NotifyHeaders = Record + +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]' +} diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts index 595b6684..919b8815 100644 --- a/apps/backend/src/types/express.d.ts +++ b/apps/backend/src/types/express.d.ts @@ -4,6 +4,7 @@ declare global { namespace Express { interface Request { adminSession?: AdminSession | null + rawBody?: string requestId?: string } }