拆分91卡券共享模块
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<string, any>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, any>
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, any>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { assertOpen91Config } from './config.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
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)
|
||||
}
|
||||
+6
-4
@@ -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 = {
|
||||
@@ -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<string, any>
|
||||
|
||||
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: '',
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user