Files
order_site/apps/backend/src/services/open-91/shared.js
T
2026-05-04 22:05:54 +08:00

344 lines
9.6 KiB
JavaScript

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
export function getOpen91Config() {
const config = /** @type {import('../../types/runtime-config.js').RuntimeConfig['platforms']['ninetyone']} */ (
runtimeConfig.platforms?.ninetyone || {}
)
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.userId) {
throw createHttpError('91卡券 userId 未配置', {
statusCode: 500,
errorCode: 'open91_missing_user_id',
})
}
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) {
return String(value || '').trim()
}
export function normalizeOpen91CreatePayload(payload = {}) {
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 = {}) {
return {
orderNo: normalizeOpen91String(payload.orderNo),
timestamp: normalizeOpen91Timestamp(payload.timestamp),
version: normalizeOpen91String(payload.version),
sign: normalizeOpen91String(payload.sign).toUpperCase(),
}
}
export function assertOpen91CreatePayload(payload, 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, config = assertOpen91Config()) {
if (!payload.orderNo) {
throw createHttpError('缺少 orderNo', {
statusCode: 400,
errorCode: 'open91_missing_order_no',
})
}
assertOpen91CommonPayload(payload, config)
}
function assertOpen91CommonPayload(payload, config) {
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, 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 = {}, { userId, secret } = assertOpen91Config()) {
const entries = Object.entries({
...params,
userId: normalizeOpen91String(userId),
})
.filter(([key]) => key !== 'sign')
.sort(([left], [right]) => left.localeCompare(right))
const queryString = entries
.map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`)
.join('&')
return `${secret}${queryString}${secret}`
}
export function signOpen91Payload(params = {}, config = assertOpen91Config()) {
return crypto
.createHash('md5')
.update(buildOpen91SignSource(params, config), 'utf8')
.digest('hex')
.toUpperCase()
}
export function verifyOpen91Signature(params = {}, config = assertOpen91Config()) {
const expected = signOpen91Payload(params, config)
const actual = normalizeOpen91String(params.sign).toUpperCase()
return expected === actual
}
export function assertOpen91Signature(params = {}, config = assertOpen91Config()) {
if (!verifyOpen91Signature(params, config)) {
throw createHttpError('验签失败', {
statusCode: 400,
errorCode: 'open91_invalid_signature',
})
}
}
export function encryptOpen91Cards(cards = [], 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 = [], 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, message = OPEN_91_SUCCESS_MESSAGE) {
return {
code: 200,
message,
data,
}
}
export function buildOpen91ErrorResponse(message, code = 500) {
return {
code,
message: normalizeOpen91String(message) || '系统错误',
data: null,
}
}
export function buildOpen91OutTradeNo(order) {
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 = '' } = {}) {
const normalizedClaimUrl = normalizeOpen91String(claimUrl)
return {
cardNo: normalizedClaimUrl,
cardPwd: '',
expireTime: normalizeOpen91String(expireTime),
jumpLink: normalizedClaimUrl,
}
}
export function resolveOpen91QueryState({ tasks = [], readyTaskIds = [] } = {}) {
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) {
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) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : 0
}
function normalizeOpen91OptionalAmount(value) {
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) {
const normalized = normalizeOpen91String(value)
if (!/^\d{10}$/.test(normalized)) {
return 0
}
return Number(normalized)
}