From 8d5871bf926497503bcf6fc37fab570434fa4b7d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 09:03:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=8691=E5=8D=A1=E5=88=B8=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/open-91.ts | 2 +- apps/backend/src/services/open-91/config.ts | 44 +++ apps/backend/src/services/open-91/crypto.ts | 91 +++++ .../services/open-91/order-create-service.ts | 14 +- .../services/open-91/order-query-service.ts | 23 +- apps/backend/src/services/open-91/payload.ts | 136 +++++++ .../{shared.test.ts => protocol.test.ts} | 10 +- apps/backend/src/services/open-91/response.ts | 115 ++++++ apps/backend/src/services/open-91/shared.ts | 370 ------------------ .../platforms/ninetyone/order-service.ts | 6 +- 10 files changed, 418 insertions(+), 393 deletions(-) create mode 100644 apps/backend/src/services/open-91/config.ts create mode 100644 apps/backend/src/services/open-91/crypto.ts create mode 100644 apps/backend/src/services/open-91/payload.ts rename apps/backend/src/services/open-91/{shared.test.ts => protocol.test.ts} (98%) create mode 100644 apps/backend/src/services/open-91/response.ts delete mode 100644 apps/backend/src/services/open-91/shared.ts diff --git a/apps/backend/src/routes/open-91.ts b/apps/backend/src/routes/open-91.ts index af0a1d34..74fd3b43 100644 --- a/apps/backend/src/routes/open-91.ts +++ b/apps/backend/src/routes/open-91.ts @@ -3,7 +3,7 @@ import { Router } from 'express' import { createRateLimitMiddleware } from '../middleware/rate-limit.js' import { createOpen91Order } from '../services/open-91/order-create-service.js' import { queryOpen91Order } from '../services/open-91/order-query-service.js' -import { buildOpen91ErrorResponse } from '../services/open-91/shared.js' +import { buildOpen91ErrorResponse } from '../services/open-91/response.js' import { createRequestId, logIntegration } from '../utils/logger.js' const router = Router() diff --git a/apps/backend/src/services/open-91/config.ts b/apps/backend/src/services/open-91/config.ts new file mode 100644 index 00000000..49bc5ff2 --- /dev/null +++ b/apps/backend/src/services/open-91/config.ts @@ -0,0 +1,44 @@ +import { runtimeConfig } from '../../config/runtime.js' +import { createHttpError } from '../../utils/http.js' + +export const OPEN_91_PROVIDER = '91kaquan' +export const OPEN_91_PLATFORM = 'kuaishou' +export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' +export const OPEN_91_DEFAULT_FAIL_CODE = 1204 +export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 + +type JsonObject = Record + +export function getOpen91Config() { + const config = (runtimeConfig.platforms?.ninetyone || {}) as JsonObject + + return { + userId: String(config.userId || '').trim(), + secret: String(config.secret || '').trim(), + version: String(config.version || '1.0').trim() || '1.0', + shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, + shopName: String(config.shopName || '91卡券').trim() || '91卡券', + timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, + cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', + } +} + +export function assertOpen91Config() { + const config = getOpen91Config() + + if (!config.secret) { + throw createHttpError('91卡券 secret 未配置', { + statusCode: 500, + errorCode: 'open91_missing_secret', + }) + } + + if (config.secret.length !== 32) { + throw createHttpError('91卡券 secret 长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_secret_length', + }) + } + + return config +} diff --git a/apps/backend/src/services/open-91/crypto.ts b/apps/backend/src/services/open-91/crypto.ts new file mode 100644 index 00000000..6b4be93d --- /dev/null +++ b/apps/backend/src/services/open-91/crypto.ts @@ -0,0 +1,91 @@ +import crypto from 'node:crypto' + +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { + const entries = Object.entries(params) + .filter(([key]) => key !== 'sign') + .sort(([left], [right]) => { + if (left === right) { + return 0 + } + return left < right ? -1 : 1 + }) + + const queryString = entries + .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) + .join('&') + + return `${secret}${queryString}${secret}` +} + +export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { + return crypto + .createHash('md5') + .update(buildOpen91SignSource(params, config), 'utf8') + .digest('hex') + .toUpperCase() +} + +export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + const expected = signOpen91Payload(params, config) + const actual = normalizeOpen91String(params.sign).toUpperCase() + return expected === actual +} + +export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + if (!verifyOpen91Signature(params, config)) { + throw createHttpError('验签失败', { + statusCode: 400, + errorCode: 'open91_invalid_signature', + }) + } +} + +export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { + const normalizedSecret = normalizeOpen91String(secret) + if (normalizedSecret.length !== 32) { + throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_cards_secret_length', + }) + } + + const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) + const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) + cipher.setAutoPadding(true) + + return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` +} + +export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { + const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() + if (encoding && encoding !== 'aes-256-ecb-base64') { + throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { + statusCode: 500, + errorCode: 'open91_unsupported_cards_encoding', + }) + } + + return encryptOpen91Cards(cards, config.secret) +} + +export function stringifyOpen91SignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + if (value == null) { + return '' + } + + return String(value) +} diff --git a/apps/backend/src/services/open-91/order-create-service.ts b/apps/backend/src/services/open-91/order-create-service.ts index 68f96387..03547abc 100644 --- a/apps/backend/src/services/open-91/order-create-service.ts +++ b/apps/backend/src/services/open-91/order-create-service.ts @@ -8,16 +8,20 @@ import { import { notifyOpen91PendingConfig } from '../notification/domain-notifications.js' import { assertOpen91Config, + OPEN_91_COST_EXCEED_FAIL_CODE, + OPEN_91_DEFAULT_FAIL_CODE, +} from './config.js' +import { assertOpen91CreatePayload, - assertOpen91Signature, assertOpen91Timestamp, + normalizeOpen91CreatePayload, +} from './payload.js' +import { assertOpen91Signature } from './crypto.js' +import { buildOpen91OrderCost, buildOpen91OutTradeNo, buildOpen91SuccessResponse, - normalizeOpen91CreatePayload, - OPEN_91_COST_EXCEED_FAIL_CODE, - OPEN_91_DEFAULT_FAIL_CODE, -} from './shared.js' +} from './response.js' export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { const config = assertOpen91Config() diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index ffd31a40..034862af 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -9,20 +9,23 @@ import { } from '../platforms/ninetyone/order-service.js' import { assertOpen91Config, - assertOpen91QueryPayload, - assertOpen91Signature, - assertOpen91Timestamp, - buildOpen91CardItem, - buildOpen91Cards, - buildOpen91OrderCost, - buildOpen91OutTradeNo, - buildOpen91SuccessResponse, - normalizeOpen91QueryPayload, OPEN_91_DEFAULT_FAIL_CODE, OPEN_91_PLATFORM, OPEN_91_PROVIDER, +} from './config.js' +import { + assertOpen91QueryPayload, + assertOpen91Timestamp, + normalizeOpen91QueryPayload, +} from './payload.js' +import { assertOpen91Signature, buildOpen91Cards } from './crypto.js' +import { + buildOpen91CardItem, + buildOpen91OrderCost, + buildOpen91OutTradeNo, + buildOpen91SuccessResponse, resolveOpen91QueryState, -} from './shared.js' +} from './response.js' import type { OrderRow } from '../../types/repository-rows.js' type JsonObject = Record diff --git a/apps/backend/src/services/open-91/payload.ts b/apps/backend/src/services/open-91/payload.ts new file mode 100644 index 00000000..80edbf6e --- /dev/null +++ b/apps/backend/src/services/open-91/payload.ts @@ -0,0 +1,136 @@ +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' + +type JsonObject = Record + +export function normalizeOpen91String(value: unknown) { + return String(value || '').trim() +} + +export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + productNo: normalizeOpen91String(payload.productNo), + buyNum: normalizeOpen91BuyNum(payload.buyNum), + maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), + callbackUrl: normalizeOpen91String(payload.callbackUrl), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + if (!payload.productNo) { + throw createHttpError('缺少 productNo', { + statusCode: 400, + errorCode: 'open91_missing_product_no', + }) + } + + if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { + throw createHttpError('buyNum 必须是大于 0 的整数', { + statusCode: 400, + errorCode: 'open91_invalid_buy_num', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { + throw createHttpError('timestamp 已过期', { + statusCode: 400, + errorCode: 'open91_expired_timestamp', + }) + } +} + +function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { + if (!payload.timestamp) { + throw createHttpError('缺少 timestamp', { + statusCode: 400, + errorCode: 'open91_missing_timestamp', + }) + } + + if (!payload.version) { + throw createHttpError('缺少 version', { + statusCode: 400, + errorCode: 'open91_missing_version', + }) + } + + if (payload.version !== config.version) { + throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { + statusCode: 400, + errorCode: 'open91_invalid_version', + }) + } + + if (!payload.sign) { + throw createHttpError('缺少 sign', { + statusCode: 400, + errorCode: 'open91_missing_sign', + }) + } +} + +function normalizeOpen91BuyNum(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : 0 +} + +function normalizeOpen91OptionalAmount(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (!/^\d+(?:\.\d+)?$/.test(normalized)) { + throw createHttpError('maxAmount 格式无效', { + statusCode: 400, + errorCode: 'open91_invalid_max_amount', + }) + } + + return normalized +} + +function normalizeOpen91Timestamp(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!/^\d{10}$/.test(normalized)) { + return 0 + } + + return Number(normalized) +} diff --git a/apps/backend/src/services/open-91/shared.test.ts b/apps/backend/src/services/open-91/protocol.test.ts similarity index 98% rename from apps/backend/src/services/open-91/shared.test.ts rename to apps/backend/src/services/open-91/protocol.test.ts index 27aa7866..b72625d3 100644 --- a/apps/backend/src/services/open-91/shared.test.ts +++ b/apps/backend/src/services/open-91/protocol.test.ts @@ -2,14 +2,16 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - buildOpen91CardItem, buildOpen91SignSource, encryptOpen91Cards, - formatOpen91ExpireTime, - resolveOpen91QueryState, signOpen91Payload, verifyOpen91Signature, -} from './shared.js' +} from './crypto.js' +import { + buildOpen91CardItem, + formatOpen91ExpireTime, + resolveOpen91QueryState, +} from './response.js' test('signOpen91Payload signs only request body fields', () => { const config = { diff --git a/apps/backend/src/services/open-91/response.ts b/apps/backend/src/services/open-91/response.ts new file mode 100644 index 00000000..a9c5f721 --- /dev/null +++ b/apps/backend/src/services/open-91/response.ts @@ -0,0 +1,115 @@ +import { + OPEN_91_DEFAULT_FAIL_CODE, + OPEN_91_SUCCESS_MESSAGE, +} from './config.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { + return { + code: 200, + message, + data, + } +} + +export function buildOpen91ErrorResponse(message: unknown, code = 500) { + return { + code, + message: normalizeOpen91String(message) || '系统错误', + data: null as null, + } +} + +export function buildOpen91OutTradeNo(order: JsonObject) { + const orderId = Number(order?.id || 0) + return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' +} + +export function buildOpen91OrderCost(value = 0) { + const amount = Number(value) + return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' +} + +export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { + const normalizedClaimUrl = normalizeOpen91String(claimUrl) + return { + cardNo: normalizedClaimUrl, + cardPwd: '', + expireTime: formatOpen91ExpireTime(expireTime), + jumpLink: normalizedClaimUrl, + } +} + +export function formatOpen91ExpireTime(value: unknown = '') { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { + return normalized + } + + const date = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(date.getTime())) { + return normalized + } + + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) + return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` +} + +export function resolveOpen91QueryState( + { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, +) { + const normalizedTasks = Array.isArray(tasks) ? tasks : [] + const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) + + if (normalizedTasks.length === 0) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: '订单未生成履约任务', + } + } + + const failedTask = normalizedTasks.find((task) => { + const status = normalizeOpen91String(task?.task_status) + return ['failed', 'manual_review', 'closed'].includes(status) && !readyTaskIdSet.has(Number(task?.id || 0)) + }) + + if (failedTask) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', + } + } + + if (readyTaskIdSet.size < normalizedTasks.length) { + return { + orderStatus: 10, + failCode: 0, + failReason: '', + } + } + + return { + orderStatus: 20, + failCode: 0, + failReason: '', + } +} diff --git a/apps/backend/src/services/open-91/shared.ts b/apps/backend/src/services/open-91/shared.ts deleted file mode 100644 index 41c47303..00000000 --- a/apps/backend/src/services/open-91/shared.ts +++ /dev/null @@ -1,370 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { createHttpError } from '../../utils/http.js' - -export const OPEN_91_PROVIDER = '91kaquan' -export const OPEN_91_PLATFORM = 'kuaishou' -export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' -export const OPEN_91_DEFAULT_FAIL_CODE = 1204 -export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 - -type JsonObject = Record - -export function getOpen91Config() { - const config = (runtimeConfig.platforms?.ninetyone || {}) as JsonObject - - return { - userId: String(config.userId || '').trim(), - secret: String(config.secret || '').trim(), - version: String(config.version || '1.0').trim() || '1.0', - shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, - shopName: String(config.shopName || '91卡券').trim() || '91卡券', - timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, - cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', - } -} - -export function assertOpen91Config() { - const config = getOpen91Config() - - if (!config.secret) { - throw createHttpError('91卡券 secret 未配置', { - statusCode: 500, - errorCode: 'open91_missing_secret', - }) - } - - if (config.secret.length !== 32) { - throw createHttpError('91卡券 secret 长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_secret_length', - }) - } - - return config -} - -export function normalizeOpen91String(value: unknown) { - return String(value || '').trim() -} - -export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - productNo: normalizeOpen91String(payload.productNo), - buyNum: normalizeOpen91BuyNum(payload.buyNum), - maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), - callbackUrl: normalizeOpen91String(payload.callbackUrl), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - if (!payload.productNo) { - throw createHttpError('缺少 productNo', { - statusCode: 400, - errorCode: 'open91_missing_product_no', - }) - } - - if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { - throw createHttpError('buyNum 必须是大于 0 的整数', { - statusCode: 400, - errorCode: 'open91_invalid_buy_num', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { - if (!payload.timestamp) { - throw createHttpError('缺少 timestamp', { - statusCode: 400, - errorCode: 'open91_missing_timestamp', - }) - } - - if (!payload.version) { - throw createHttpError('缺少 version', { - statusCode: 400, - errorCode: 'open91_missing_version', - }) - } - - if (payload.version !== config.version) { - throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { - statusCode: 400, - errorCode: 'open91_invalid_version', - }) - } - - if (!payload.sign) { - throw createHttpError('缺少 sign', { - statusCode: 400, - errorCode: 'open91_missing_sign', - }) - } -} - -export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { - const nowSeconds = Math.floor(Date.now() / 1000) - if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { - throw createHttpError('timestamp 已过期', { - statusCode: 400, - errorCode: 'open91_expired_timestamp', - }) - } -} - -export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign') - .sort(([left], [right]) => { - if (left === right) { - return 0 - } - return left < right ? -1 : 1 - }) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) - .join('&') - - return `${secret}${queryString}${secret}` -} - -export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { - return crypto - .createHash('md5') - .update(buildOpen91SignSource(params, config), 'utf8') - .digest('hex') - .toUpperCase() -} - -export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - const expected = signOpen91Payload(params, config) - const actual = normalizeOpen91String(params.sign).toUpperCase() - return expected === actual -} - -export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - if (!verifyOpen91Signature(params, config)) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'open91_invalid_signature', - }) - } -} - -export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { - const normalizedSecret = normalizeOpen91String(secret) - if (normalizedSecret.length !== 32) { - throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_cards_secret_length', - }) - } - - const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) - const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) - cipher.setAutoPadding(true) - - return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` -} - -export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { - const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() - if (encoding && encoding !== 'aes-256-ecb-base64') { - throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { - statusCode: 500, - errorCode: 'open91_unsupported_cards_encoding', - }) - } - - return encryptOpen91Cards(cards, config.secret) -} - -export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { - return { - code: 200, - message, - data, - } -} - -export function buildOpen91ErrorResponse(message: unknown, code = 500) { - return { - code, - message: normalizeOpen91String(message) || '系统错误', - data: null as null, - } -} - -export function buildOpen91OutTradeNo(order: JsonObject) { - const orderId = Number(order?.id || 0) - return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' -} - -export function buildOpen91OrderCost(value = 0) { - const amount = Number(value) - return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' -} - -export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { - const normalizedClaimUrl = normalizeOpen91String(claimUrl) - return { - cardNo: normalizedClaimUrl, - cardPwd: '', - expireTime: formatOpen91ExpireTime(expireTime), - jumpLink: normalizedClaimUrl, - } -} - -export function formatOpen91ExpireTime(value: unknown = '') { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { - return normalized - } - - const date = value instanceof Date ? value : new Date(normalized) - if (Number.isNaN(date.getTime())) { - return normalized - } - - const parts = new Intl.DateTimeFormat('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(date) - - const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) - return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` -} - -export function resolveOpen91QueryState( - { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, -) { - const normalizedTasks = Array.isArray(tasks) ? tasks : [] - const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) - - if (normalizedTasks.length === 0) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: '订单未生成履约任务', - } - } - - const failedTask = normalizedTasks.find((task) => { - const status = normalizeOpen91String(task?.task_status) - return ['failed', 'manual_review', 'closed'].includes(status) && !readyTaskIdSet.has(Number(task?.id || 0)) - }) - - if (failedTask) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', - } - } - - if (readyTaskIdSet.size < normalizedTasks.length) { - return { - orderStatus: 10, - failCode: 0, - failReason: '', - } - } - - return { - orderStatus: 20, - failCode: 0, - failReason: '', - } -} - -export function stringifyOpen91SignValue(value: unknown) { - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - if (value == null) { - return '' - } - - return String(value) -} - -function normalizeOpen91BuyNum(value: unknown) { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : 0 -} - -function normalizeOpen91OptionalAmount(value: unknown) { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (!/^\d+(?:\.\d+)?$/.test(normalized)) { - throw createHttpError('maxAmount 格式无效', { - statusCode: 400, - errorCode: 'open91_invalid_max_amount', - }) - } - - return normalized -} - -function normalizeOpen91Timestamp(value: unknown) { - const normalized = normalizeOpen91String(value) - if (!/^\d{10}$/.test(normalized)) { - return 0 - } - - return Number(normalized) -} diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 6e6f6af5..c03c0107 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -4,11 +4,11 @@ import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositorie import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' import { - buildOpen91OutTradeNo, - normalizeOpen91CreatePayload, OPEN_91_PLATFORM, OPEN_91_PROVIDER, -} from '../../open-91/shared.js' +} from '../../open-91/config.js' +import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' +import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import type { OrderItemRow, OrderRow } from '../../../types/repository-rows.js'