91kaquan 初步介入
This commit is contained in:
@@ -69,6 +69,15 @@ module.exports = {
|
|||||||
shops: {},
|
shops: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ninetyone: {
|
||||||
|
userId: '',
|
||||||
|
secret: '',
|
||||||
|
version: '1.0',
|
||||||
|
shopId: '91kaquan',
|
||||||
|
shopName: '91卡券',
|
||||||
|
timestampToleranceSeconds: 600,
|
||||||
|
cardsEncoding: 'aes-256-ecb-base64',
|
||||||
|
},
|
||||||
khhao: {
|
khhao: {
|
||||||
baseUrl: 'https://admin.khhao.com',
|
baseUrl: 'https://admin.khhao.com',
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"syncFromCreatedAt": "2026-05-02T10:53:28.550Z",
|
"syncFromCreatedAt": "2026-05-02T10:53:28.550Z",
|
||||||
"lastRunStartedAt": "2026-05-04T13:32:11.217Z",
|
"lastRunStartedAt": "2026-05-04T14:03:19.832Z",
|
||||||
"lastRunFinishedAt": "2026-05-04T13:32:11.824Z",
|
"lastRunFinishedAt": "2026-05-04T14:03:20.530Z",
|
||||||
"lastRunStatus": "success",
|
"lastRunStatus": "success",
|
||||||
"lastErrorMessage": "",
|
"lastErrorMessage": "",
|
||||||
"fetchedCount": 40,
|
"fetchedCount": 40,
|
||||||
"syncedCount": 0,
|
"syncedCount": 0,
|
||||||
"ignoredCount": 40,
|
"ignoredCount": 40,
|
||||||
"lastOrderCreatedAt": "2026-05-04T13:32:06.000Z",
|
"lastOrderCreatedAt": "2026-05-04T14:02:50.000Z",
|
||||||
"watchMode": "normal",
|
"watchMode": "normal",
|
||||||
"watchOrders": []
|
"watchOrders": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,41 @@ function applyEnvOverrides(baseConfig) {
|
|||||||
nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled
|
nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ninetyoneUserId = String(process.env.NINETYONE_USER_ID || '').trim()
|
||||||
|
if (ninetyoneUserId) {
|
||||||
|
nextConfig.platforms.ninetyone.userId = ninetyoneUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneSecret = String(process.env.NINETYONE_SECRET || '').trim()
|
||||||
|
if (ninetyoneSecret) {
|
||||||
|
nextConfig.platforms.ninetyone.secret = ninetyoneSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneVersion = String(process.env.NINETYONE_VERSION || '').trim()
|
||||||
|
if (ninetyoneVersion) {
|
||||||
|
nextConfig.platforms.ninetyone.version = ninetyoneVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneShopId = String(process.env.NINETYONE_SHOP_ID || '').trim()
|
||||||
|
if (ninetyoneShopId) {
|
||||||
|
nextConfig.platforms.ninetyone.shopId = ninetyoneShopId
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneShopName = String(process.env.NINETYONE_SHOP_NAME || '').trim()
|
||||||
|
if (ninetyoneShopName) {
|
||||||
|
nextConfig.platforms.ninetyone.shopName = ninetyoneShopName
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneTimestampToleranceSeconds = parseInteger(process.env.NINETYONE_TIMESTAMP_TOLERANCE_SECONDS)
|
||||||
|
if (ninetyoneTimestampToleranceSeconds !== null) {
|
||||||
|
nextConfig.platforms.ninetyone.timestampToleranceSeconds = ninetyoneTimestampToleranceSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const ninetyoneCardsEncoding = String(process.env.NINETYONE_CARDS_ENCODING || '').trim()
|
||||||
|
if (ninetyoneCardsEncoding) {
|
||||||
|
nextConfig.platforms.ninetyone.cardsEncoding = ninetyoneCardsEncoding
|
||||||
|
}
|
||||||
|
|
||||||
const khhaoBaseUrl = String(process.env.KHHAO_BASE_URL || '').trim()
|
const khhaoBaseUrl = String(process.env.KHHAO_BASE_URL || '').trim()
|
||||||
if (khhaoBaseUrl) {
|
if (khhaoBaseUrl) {
|
||||||
nextConfig.platforms.khhao.baseUrl = khhaoBaseUrl
|
nextConfig.platforms.khhao.baseUrl = khhaoBaseUrl
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { runtimeConfig } from './config/runtime.js'
|
|||||||
import { runDatabaseMigrations } from './db/migrate.js'
|
import { runDatabaseMigrations } from './db/migrate.js'
|
||||||
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 open91Router from './routes/open-91.js'
|
||||||
import webhooksRouter from './routes/webhooks.js'
|
import webhooksRouter from './routes/webhooks.js'
|
||||||
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
||||||
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
||||||
@@ -118,6 +119,7 @@ app.use((req, res, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/v1/tencent', tencentRouter)
|
app.use('/api/v1/tencent', tencentRouter)
|
||||||
|
app.use('/api/v1/open/91', open91Router)
|
||||||
app.use('/api/v1/webhooks', webhooksRouter)
|
app.use('/api/v1/webhooks', webhooksRouter)
|
||||||
app.use('/api/v1/claim', claimsRouter)
|
app.use('/api/v1/claim', claimsRouter)
|
||||||
app.use('/api/v1/admin', adminRouter)
|
app.use('/api/v1/admin', adminRouter)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
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 { createRequestId, logWebhook } from '../utils/logger.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/orders/create', async (req, res) => {
|
||||||
|
const requestId = createRequestId('91')
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
logWebhook('[open-91/create]', '收到 91卡券异步下单请求', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
originalUrl: req.originalUrl,
|
||||||
|
ip: req.ip,
|
||||||
|
body: req.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createOpen91Order(req.body, { requestId })
|
||||||
|
logWebhook('[open-91/create]', '91卡券异步下单处理完成', {
|
||||||
|
requestId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
res.status(200).json(result)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
|
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||||
|
logWebhook('[open-91/create]', '91卡券异步下单处理失败', {
|
||||||
|
requestId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
error,
|
||||||
|
}, { level: 'error' })
|
||||||
|
res.status(200).json(buildOpen91ErrorResponse(message, code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/orders/query', async (req, res) => {
|
||||||
|
const requestId = createRequestId('91')
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
logWebhook('[open-91/query]', '收到 91卡券订单查询请求', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
originalUrl: req.originalUrl,
|
||||||
|
ip: req.ip,
|
||||||
|
body: req.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await queryOpen91Order(req.body, { requestId })
|
||||||
|
logWebhook('[open-91/query]', '91卡券订单查询处理完成', {
|
||||||
|
requestId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
res.status(200).json(result)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
|
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||||
|
logWebhook('[open-91/query]', '91卡券订单查询处理失败', {
|
||||||
|
requestId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
error,
|
||||||
|
}, { level: 'error' })
|
||||||
|
res.status(200).json(buildOpen91ErrorResponse(message, code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { hasConfiguredOrderItems } from '../order/product-match-service.js'
|
||||||
|
import { upsertOrderFromSource } from '../order/order-service.js'
|
||||||
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { logWebhook } from '../../utils/logger.js'
|
||||||
|
import { parseAmountToFen } from '../../utils/money.js'
|
||||||
|
import {
|
||||||
|
assertOpen91Config,
|
||||||
|
assertOpen91CreatePayload,
|
||||||
|
assertOpen91Signature,
|
||||||
|
assertOpen91Timestamp,
|
||||||
|
buildOpen91OrderCost,
|
||||||
|
buildOpen91OutTradeNo,
|
||||||
|
buildOpen91SuccessResponse,
|
||||||
|
normalizeOpen91CreatePayload,
|
||||||
|
OPEN_91_COST_EXCEED_FAIL_CODE,
|
||||||
|
OPEN_91_DEFAULT_FAIL_CODE,
|
||||||
|
OPEN_91_PLATFORM,
|
||||||
|
OPEN_91_PROVIDER,
|
||||||
|
} from './shared.js'
|
||||||
|
|
||||||
|
export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
|
||||||
|
const config = assertOpen91Config()
|
||||||
|
const normalized = normalizeOpen91CreatePayload(payload)
|
||||||
|
|
||||||
|
assertOpen91CreatePayload(normalized, config)
|
||||||
|
assertOpen91Timestamp(normalized.timestamp, config.timestampToleranceSeconds)
|
||||||
|
assertOpen91Signature(normalized, config)
|
||||||
|
|
||||||
|
const now = nowIso()
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
externalItemId: normalized.productNo,
|
||||||
|
externalSkuCode: normalized.productNo,
|
||||||
|
externalSkuName: normalized.productNo,
|
||||||
|
quantity: normalized.buyNum,
|
||||||
|
spec: {
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
productNo: normalized.productNo,
|
||||||
|
callbackUrl: normalized.callbackUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const configured = await hasConfiguredOrderItems({
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: config.shopId,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: '',
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: OPEN_91_DEFAULT_FAIL_CODE,
|
||||||
|
failReason: '商品未配置或未匹配到履约规则',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.maxAmount) {
|
||||||
|
const maxAmountFen = parseAmountToFen(normalized.maxAmount)
|
||||||
|
if (maxAmountFen < 0) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: '',
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: OPEN_91_COST_EXCEED_FAIL_CODE,
|
||||||
|
failReason: '订单成本超出可接受范围',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await upsertOrderFromSource({
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: config.shopId,
|
||||||
|
shopName: config.shopName,
|
||||||
|
platformOrderId: normalized.orderNo,
|
||||||
|
orderStatus: 'paid',
|
||||||
|
payStatus: 'paid',
|
||||||
|
buyerId: '',
|
||||||
|
buyerName: '',
|
||||||
|
receiverContact: '',
|
||||||
|
totalAmount: 0,
|
||||||
|
currency: 'CNY',
|
||||||
|
rawPayload: {
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
...normalized,
|
||||||
|
},
|
||||||
|
paidAt: now,
|
||||||
|
items,
|
||||||
|
}, {
|
||||||
|
sourceLabel: 'open91',
|
||||||
|
})
|
||||||
|
|
||||||
|
logWebhook('[open-91/create]', '91卡券下单已处理', {
|
||||||
|
requestId,
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
productNo: normalized.productNo,
|
||||||
|
buyNum: normalized.buyNum,
|
||||||
|
configured,
|
||||||
|
ignored: Boolean(result.ignored),
|
||||||
|
orderId: result.order?.id || null,
|
||||||
|
taskCount: Array.isArray(result.tasks) ? result.tasks.length : 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.ignored || !result.order || !Array.isArray(result.tasks) || result.tasks.length === 0) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: '',
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: OPEN_91_DEFAULT_FAIL_CODE,
|
||||||
|
failReason: '商品未配置或订单无法履约',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: buildOpen91OutTradeNo(result.order),
|
||||||
|
orderStatus: 10,
|
||||||
|
orderCost: buildOpen91OrderCost(0),
|
||||||
|
cards: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
||||||
|
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
|
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js'
|
||||||
|
import { logWebhook } from '../../utils/logger.js'
|
||||||
|
import {
|
||||||
|
assertOpen91Config,
|
||||||
|
assertOpen91QueryPayload,
|
||||||
|
assertOpen91Signature,
|
||||||
|
assertOpen91Timestamp,
|
||||||
|
buildOpen91CardItem,
|
||||||
|
buildOpen91Cards,
|
||||||
|
buildOpen91OrderCost,
|
||||||
|
buildOpen91OutTradeNo,
|
||||||
|
buildOpen91SuccessResponse,
|
||||||
|
normalizeOpen91QueryPayload,
|
||||||
|
OPEN_91_DEFAULT_FAIL_CODE,
|
||||||
|
OPEN_91_PLATFORM,
|
||||||
|
OPEN_91_PROVIDER,
|
||||||
|
resolveOpen91QueryState,
|
||||||
|
} from './shared.js'
|
||||||
|
|
||||||
|
export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
|
||||||
|
const config = assertOpen91Config()
|
||||||
|
const normalized = normalizeOpen91QueryPayload(payload)
|
||||||
|
|
||||||
|
assertOpen91QueryPayload(normalized, config)
|
||||||
|
assertOpen91Timestamp(normalized.timestamp, config.timestampToleranceSeconds)
|
||||||
|
assertOpen91Signature(normalized, config)
|
||||||
|
|
||||||
|
const order = await findOrderByPlatformOrderId({
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: config.shopId,
|
||||||
|
platformOrderId: normalized.orderNo,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: '',
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: OPEN_91_DEFAULT_FAIL_CODE,
|
||||||
|
failReason: '订单不存在',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await listTasksByOrderId(order.id)
|
||||||
|
const cardItems = []
|
||||||
|
const readyTaskIds = []
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
let claimUrl = ''
|
||||||
|
let expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim()
|
||||||
|
const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim()
|
||||||
|
|
||||||
|
if (primaryToken) {
|
||||||
|
claimUrl = buildClaimUrl(primaryToken)
|
||||||
|
} else if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
|
||||||
|
try {
|
||||||
|
const ensured = await ensureTaskClaimLink(task)
|
||||||
|
claimUrl = String(ensured.claimUrl || '').trim()
|
||||||
|
expireTime = String(ensured.expiredAt || expireTime).trim()
|
||||||
|
} catch {
|
||||||
|
claimUrl = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claimUrl) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
readyTaskIds.push(Number(task.id))
|
||||||
|
cardItems.push(buildOpen91CardItem({
|
||||||
|
claimUrl,
|
||||||
|
expireTime,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryState = resolveOpen91QueryState({
|
||||||
|
tasks,
|
||||||
|
readyTaskIds,
|
||||||
|
})
|
||||||
|
|
||||||
|
logWebhook('[open-91/query]', '91卡券订单查询已处理', {
|
||||||
|
requestId,
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
orderId: order.id,
|
||||||
|
taskCount: tasks.length,
|
||||||
|
readyTaskCount: readyTaskIds.length,
|
||||||
|
orderStatus: queryState.orderStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (queryState.orderStatus === 30) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: buildOpen91OutTradeNo(order),
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: queryState.failCode,
|
||||||
|
failReason: queryState.failReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryState.orderStatus === 10) {
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: buildOpen91OutTradeNo(order),
|
||||||
|
orderStatus: 10,
|
||||||
|
failCode: 0,
|
||||||
|
failReason: '',
|
||||||
|
orderCost: buildOpen91OrderCost(0),
|
||||||
|
cards: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOpen91SuccessResponse({
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
outTradeNo: buildOpen91OutTradeNo(order),
|
||||||
|
orderStatus: 20,
|
||||||
|
failCode: 0,
|
||||||
|
failReason: '',
|
||||||
|
orderCost: buildOpen91OrderCost(0),
|
||||||
|
cards: buildOpen91Cards(cardItems, config),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOpen91CardItem,
|
||||||
|
buildOpen91SignSource,
|
||||||
|
encryptOpen91Cards,
|
||||||
|
resolveOpen91QueryState,
|
||||||
|
signOpen91Payload,
|
||||||
|
verifyOpen91Signature,
|
||||||
|
} from './shared.js'
|
||||||
|
|
||||||
|
test('signOpen91Payload matches documented md5 signing rule', () => {
|
||||||
|
const config = {
|
||||||
|
userId: '1001',
|
||||||
|
secret: 'rste57w8rsubsnxsb384ur3u9kn5fzhr',
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
orderNo: 'P91KS202605040001',
|
||||||
|
productNo: 'KS-CLOUD-SKU-001',
|
||||||
|
buyNum: 1,
|
||||||
|
maxAmount: '0.0000',
|
||||||
|
callbackUrl: 'https://cb.example.com/notify/91/order',
|
||||||
|
timestamp: 1777867200,
|
||||||
|
version: '1.0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = buildOpen91SignSource(payload, config)
|
||||||
|
assert.equal(
|
||||||
|
source,
|
||||||
|
'rste57w8rsubsnxsb384ur3u9kn5fzhrbuyNum=1&callbackUrl=https://cb.example.com/notify/91/order&maxAmount=0.0000&orderNo=P91KS202605040001&productNo=KS-CLOUD-SKU-001×tamp=1777867200&userId=1001&version=1.0rste57w8rsubsnxsb384ur3u9kn5fzhr',
|
||||||
|
)
|
||||||
|
|
||||||
|
const sign = signOpen91Payload(payload, config)
|
||||||
|
assert.equal(sign.length, 32)
|
||||||
|
assert.equal(verifyOpen91Signature({ ...payload, sign }, config), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encryptOpen91Cards matches documented AES ECB base64 sample', () => {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
cardNo: '10001',
|
||||||
|
cardPwd: '123456',
|
||||||
|
expireTime: '2023-06-19 17:16:01',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
encryptOpen91Cards(cards, '0a091b3aa4324435aab703142518a8f7'),
|
||||||
|
'34APdvSJhWfr5wnzE4YzxAsI0gfI6pI/Njrj1UQMHJrZ8Dq9EWsDnln0pp08gn4KES4iQu9/f0Y8UMN4dGdSK67oi0Bc12/V8hrNxqoC3Lk=',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveOpen91QueryState returns success only when every task is ready', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveOpen91QueryState({
|
||||||
|
tasks: [{ id: 1, task_status: 'pending_binding_prepare' }, { id: 2, task_status: 'waiting_binding' }],
|
||||||
|
readyTaskIds: [1, 2],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
orderStatus: 20,
|
||||||
|
failCode: 0,
|
||||||
|
failReason: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveOpen91QueryState({
|
||||||
|
tasks: [{ id: 1, task_status: 'pending_binding_prepare' }, { id: 2, task_status: 'waiting_binding' }],
|
||||||
|
readyTaskIds: [1],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
orderStatus: 10,
|
||||||
|
failCode: 0,
|
||||||
|
failReason: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveOpen91QueryState({
|
||||||
|
tasks: [{ id: 1, task_status: 'manual_review', last_error: '商品未配置' }],
|
||||||
|
readyTaskIds: [],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
orderStatus: 30,
|
||||||
|
failCode: 1204,
|
||||||
|
failReason: '商品未配置',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildOpen91CardItem({
|
||||||
|
claimUrl: 'https://221329.cc.cd/#/claim/abc123xyz',
|
||||||
|
expireTime: '2026-05-05 12:00:00',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cardNo: 'https://221329.cc.cd/#/claim/abc123xyz',
|
||||||
|
cardPwd: '',
|
||||||
|
expireTime: '2026-05-05 12:00:00',
|
||||||
|
jumpLink: 'https://221329.cc.cd/#/claim/abc123xyz',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -89,6 +89,15 @@ export {}
|
|||||||
* shops: AgisoMessagingShopsConfig
|
* shops: AgisoMessagingShopsConfig
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
|
* ninetyone: {
|
||||||
|
* userId: string
|
||||||
|
* secret: string
|
||||||
|
* version: string
|
||||||
|
* shopId: string
|
||||||
|
* shopName: string
|
||||||
|
* timestampToleranceSeconds: number
|
||||||
|
* cardsEncoding: string
|
||||||
|
* }
|
||||||
* khhao: {
|
* khhao: {
|
||||||
* baseUrl: string
|
* baseUrl: string
|
||||||
* timeoutMs: number
|
* timeoutMs: number
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# 91卡券接入开发设计
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
基于以下对外接口文档完成当前项目接入:
|
||||||
|
|
||||||
|
- [3.异步卡密下单.md](/Users/yml/codes/order-site-workspace/docs/91卡券/3.异步卡密下单.md:1)
|
||||||
|
- [4.查询订单接口.md](/Users/yml/codes/order-site-workspace/docs/91卡券/4.查询订单接口.md:1)
|
||||||
|
|
||||||
|
目标能力:
|
||||||
|
|
||||||
|
1. `91卡券` 调用我方异步下单接口;
|
||||||
|
2. 我方将订单接入当前项目,走现有快手 Cloud 履约链路;
|
||||||
|
3. 我方生成领取链接 `https://221329.cc.cd/#/claim/{token}`;
|
||||||
|
4. `91卡券` 调用查询订单接口;
|
||||||
|
5. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
- `91卡券` 作为**新的订单来源**,不复用 `agiso`、`khhao`。
|
||||||
|
- `91卡券` 不是新的履约执行器,而是外部售卖和自动发货通道。
|
||||||
|
- 当前项目真正的交付物仍然是 `claimUrl`。
|
||||||
|
- 查询接口返回 `20` 的判断标准是“领取链接已生成并可发”,不是“快手最终兑换完成”。
|
||||||
|
- `buyNum > 1` 时,必须拆成多个 task,并返回多个 card。
|
||||||
|
|
||||||
|
## 3. 来源建模
|
||||||
|
|
||||||
|
新增独立来源标识:
|
||||||
|
|
||||||
|
- `provider = '91kaquan'`
|
||||||
|
- `platform = 'kuaishou'`
|
||||||
|
- `shopId = '91kaquan'`
|
||||||
|
- `shopName = '91卡券'`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 避免与 `agiso` webhook 语义混淆;
|
||||||
|
- 避免复用 `khhao` 的后台拉单来源;
|
||||||
|
- 便于后续单独做日志、排障和绑定配置。
|
||||||
|
|
||||||
|
## 4. 接口设计
|
||||||
|
|
||||||
|
### 4.1 路由
|
||||||
|
|
||||||
|
新增独立公开路由组:
|
||||||
|
|
||||||
|
- `POST /api/v1/open/91/orders/create`
|
||||||
|
- `POST /api/v1/open/91/orders/query`
|
||||||
|
|
||||||
|
不放进现有 `/api/v1/webhooks`,因为:
|
||||||
|
|
||||||
|
- `91` 不是 webhook 来源;
|
||||||
|
- `91` 需要自己的响应结构:`code/message/data`;
|
||||||
|
- 与现有项目 `buildSuccessPayload(code=0)` 不兼容。
|
||||||
|
|
||||||
|
### 4.2 返回格式
|
||||||
|
|
||||||
|
对 `91` 路由统一返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "接口调用成功",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
业务失败也返回 `data.orderStatus = 30` 的业务响应。
|
||||||
|
|
||||||
|
仅在真正的协议级错误下返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "验签失败",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 配置设计
|
||||||
|
|
||||||
|
新增运行时配置项:
|
||||||
|
|
||||||
|
```js
|
||||||
|
platforms: {
|
||||||
|
ninetyone: {
|
||||||
|
userId: '',
|
||||||
|
secret: '',
|
||||||
|
version: '1.0',
|
||||||
|
shopId: '91kaquan',
|
||||||
|
shopName: '91卡券',
|
||||||
|
timestampToleranceSeconds: 600,
|
||||||
|
cardsEncoding: 'base64json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议环境变量:
|
||||||
|
|
||||||
|
- `NINETYONE_USER_ID`
|
||||||
|
- `NINETYONE_SECRET`
|
||||||
|
- `NINETYONE_VERSION`
|
||||||
|
- `NINETYONE_SHOP_ID`
|
||||||
|
- `NINETYONE_SHOP_NAME`
|
||||||
|
- `NINETYONE_TIMESTAMP_TOLERANCE_SECONDS`
|
||||||
|
- `NINETYONE_CARDS_ENCODING`
|
||||||
|
|
||||||
|
## 6. 签名设计
|
||||||
|
|
||||||
|
签名规则沿用文档约定:
|
||||||
|
|
||||||
|
1. 除 `sign` 外,所有参数按 ASCII 升序排序;
|
||||||
|
2. 拼接为 QueryString;
|
||||||
|
3. 前后加商户密钥;
|
||||||
|
4. 计算大写 MD5;
|
||||||
|
5. `userId` 不在请求体中,但参与签名;
|
||||||
|
6. 空值参数参与签名。
|
||||||
|
|
||||||
|
需要实现:
|
||||||
|
|
||||||
|
- 生成签名原串;
|
||||||
|
- 验签;
|
||||||
|
- 校验 `timestamp` 是否超出容忍窗口。
|
||||||
|
|
||||||
|
## 7. 下单设计
|
||||||
|
|
||||||
|
### 7.1 输入映射
|
||||||
|
|
||||||
|
`91` 下单请求映射为内部 source event:
|
||||||
|
|
||||||
|
- `platformOrderId = orderNo`
|
||||||
|
- `provider = '91kaquan'`
|
||||||
|
- `platform = 'kuaishou'`
|
||||||
|
- `shopId = runtimeConfig.platforms.ninetyone.shopId`
|
||||||
|
- `shopName = runtimeConfig.platforms.ninetyone.shopName`
|
||||||
|
- `payStatus = 'paid'`
|
||||||
|
- `orderStatus = 'paid'`
|
||||||
|
- `items = [{ externalSkuCode: productNo, externalItemId: productNo, externalSkuName: productNo, quantity: buyNum }]`
|
||||||
|
|
||||||
|
### 7.2 为什么直接标记 paid
|
||||||
|
|
||||||
|
`91` 调用异步卡密下单接口时,业务上已经代表买家付款完成,当前项目需要立即进入履约任务创建。
|
||||||
|
|
||||||
|
### 7.3 对现有链路的复用
|
||||||
|
|
||||||
|
调用现有:
|
||||||
|
|
||||||
|
- `upsertOrderFromSource`
|
||||||
|
- `resolveOrderItemForFulfillment`
|
||||||
|
- `syncDeliveryTasksForOrder`
|
||||||
|
|
||||||
|
这样可直接复用现有:
|
||||||
|
|
||||||
|
- 商品匹配;
|
||||||
|
- 履约绑定;
|
||||||
|
- 快手 Cloud task 创建;
|
||||||
|
- claim token 生成。
|
||||||
|
|
||||||
|
### 7.4 成功判定
|
||||||
|
|
||||||
|
下单接口成功判定标准:
|
||||||
|
|
||||||
|
- 请求合法;
|
||||||
|
- 商品已匹配;
|
||||||
|
- 订单已成功写入;
|
||||||
|
- 至少创建出 1 个 task。
|
||||||
|
|
||||||
|
满足以上条件即返回:
|
||||||
|
|
||||||
|
- `orderStatus = 10`
|
||||||
|
|
||||||
|
不在下单接口返回 `20`。
|
||||||
|
|
||||||
|
## 8. 查询设计
|
||||||
|
|
||||||
|
### 8.1 查询目标
|
||||||
|
|
||||||
|
查询接口的职责不是看订单是否最终兑换完成,而是判断是否已经具备“可自动发货给买家”的内容。
|
||||||
|
|
||||||
|
这里的可交付内容就是 `claimUrl`。
|
||||||
|
|
||||||
|
### 8.2 claimUrl 判定
|
||||||
|
|
||||||
|
对订单下的每个 task:
|
||||||
|
|
||||||
|
1. 优先读取 `primary_claim_token`;
|
||||||
|
2. 若没有,则读取 `claim_token`;
|
||||||
|
3. 若仍没有,且是 `kuaishou_ct_assisted`,调用现有 `ensureTaskClaimLink(task)` 补生成;
|
||||||
|
4. 用 `buildClaimUrl(token)` 组装最终链接。
|
||||||
|
|
||||||
|
### 8.3 查询状态判定
|
||||||
|
|
||||||
|
- `30`:
|
||||||
|
- 订单不存在;
|
||||||
|
- 没有匹配到任务;
|
||||||
|
- task 进入失败/人工处理态且无法生成领取链接。
|
||||||
|
- `10`:
|
||||||
|
- 订单存在;
|
||||||
|
- 任务已创建;
|
||||||
|
- 但并非所有 task 都已准备好 `claimUrl`。
|
||||||
|
- `20`:
|
||||||
|
- 所有 task 都已有可交付的领取链接。
|
||||||
|
|
||||||
|
## 9. cards 设计
|
||||||
|
|
||||||
|
### 9.1 card 字段映射
|
||||||
|
|
||||||
|
每个 task 映射为一个 card:
|
||||||
|
|
||||||
|
- `cardNo = claimUrl`
|
||||||
|
- `cardPwd = ''`
|
||||||
|
- `expireTime = claim_expires_at`
|
||||||
|
- `jumpLink = claimUrl`
|
||||||
|
|
||||||
|
### 9.2 编码方案
|
||||||
|
|
||||||
|
按 [卡密加密说明.md](/Users/yml/codes/order-site-workspace/docs/91卡券/卡密加密说明.md:1) 实现:
|
||||||
|
|
||||||
|
- 算法:`AES`
|
||||||
|
- 模式:`ECB`
|
||||||
|
- 填充:`PKCS7Padding`
|
||||||
|
- 数据块:`128 位`
|
||||||
|
- 输出:`Base64`
|
||||||
|
- 密钥:开放平台 `AppSecret`,长度固定 `32` 个字符
|
||||||
|
|
||||||
|
Node 侧实现等价为:
|
||||||
|
|
||||||
|
- `aes-256-ecb`
|
||||||
|
- 开启自动填充
|
||||||
|
- 明文为 `JSON.stringify(cards)`
|
||||||
|
- 输出 Base64 字符串
|
||||||
|
|
||||||
|
编码层仍然保持独立模块,后续如果联调方要求兼容别的编码方式,只替换该模块即可。
|
||||||
|
|
||||||
|
### 9.3 多数量处理
|
||||||
|
|
||||||
|
若 `buyNum = N`:
|
||||||
|
|
||||||
|
- 内部创建 `N` 个 task;
|
||||||
|
- 查询成功时返回 `N` 个 card;
|
||||||
|
- 每个 card 对应一个独立领取链接。
|
||||||
|
|
||||||
|
## 10. 商品匹配设计
|
||||||
|
|
||||||
|
当前项目的订单商品匹配依赖:
|
||||||
|
|
||||||
|
- `provider`
|
||||||
|
- `platform`
|
||||||
|
- `shopId`
|
||||||
|
- `externalSkuCode` / `externalItemId` / `externalSkuName`
|
||||||
|
|
||||||
|
因此接入 `91` 后,运营侧需要新增对应绑定规则,使:
|
||||||
|
|
||||||
|
- `provider = '91kaquan'`
|
||||||
|
- `platform = 'kuaishou'`
|
||||||
|
- `shopId = '91kaquan'`
|
||||||
|
- `externalSkuCode = productNo`
|
||||||
|
|
||||||
|
最终匹配到现有快手 Cloud 履约配置。
|
||||||
|
|
||||||
|
## 11. 日志与排障
|
||||||
|
|
||||||
|
建议所有 `91` 请求单独打日志,至少记录:
|
||||||
|
|
||||||
|
- requestId
|
||||||
|
- orderNo
|
||||||
|
- productNo
|
||||||
|
- query/create
|
||||||
|
- 验签结果
|
||||||
|
- 命中 SKU
|
||||||
|
- orderId
|
||||||
|
- task 数量
|
||||||
|
- 最终返回的 `orderStatus`
|
||||||
|
|
||||||
|
## 12. 代码落点
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
- `apps/backend/src/routes/open-91.js`
|
||||||
|
- `apps/backend/src/services/open-91/shared.js`
|
||||||
|
- `apps/backend/src/services/open-91/order-create-service.js`
|
||||||
|
- `apps/backend/src/services/open-91/order-query-service.js`
|
||||||
|
|
||||||
|
需要修改:
|
||||||
|
|
||||||
|
- `apps/backend/config/default.cjs`
|
||||||
|
- `apps/backend/src/config/runtime.js`
|
||||||
|
- `apps/backend/src/types/runtime-config.js`
|
||||||
|
- `apps/backend/src/index.js`
|
||||||
|
|
||||||
|
## 13. 测试范围
|
||||||
|
|
||||||
|
至少补这些单测:
|
||||||
|
|
||||||
|
1. 验签:
|
||||||
|
- 正常签名通过;
|
||||||
|
- 错误签名失败;
|
||||||
|
- 空值参数参与签名。
|
||||||
|
2. 下单:
|
||||||
|
- 业务请求映射为内部 source event;
|
||||||
|
- 商品未配置时返回 `30`。
|
||||||
|
3. 查询:
|
||||||
|
- 全部 task 都有链接时返回 `20`;
|
||||||
|
- 部分 task 缺链接时返回 `10`;
|
||||||
|
- 无订单时返回 `30`。
|
||||||
|
4. cards:
|
||||||
|
- 单卡编码正确;
|
||||||
|
- 多卡数量与 task 数量一致。
|
||||||
|
|
||||||
|
## 14. 当前阶段不做的事
|
||||||
|
|
||||||
|
- 不实现 `callbackUrl` 主动回调;
|
||||||
|
- 不在 `91` 查询成功后回写快手侧最终兑换结果;
|
||||||
|
- 不实现除 `aes-256-ecb-base64` 之外的卡密加密算法;
|
||||||
|
- 不新增专门的后台管理页面。
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
### 简要描述:
|
||||||
|
- AES/加密模式ECB/填充PKCS7Padding/数据块128位 <font color='red'>注意:加密密钥为开放平台的AppSecret(从<a href='https://open.agiso.com/#/my/application/app-list' target='_blank'>open.agiso.com</a>上查看)</font>
|
||||||
|
|
||||||
|
**假设cards为:**
|
||||||
|
```
|
||||||
|
[{"cardNo":"10001","cardPwd":"123456","expireTime":"2023-06-19 17:16:01"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**假设开放平台的appSecret为:** <font color='red'>0a091b3aa4324435aab703142518a8f7</font>(从<a href='https://open.agiso.com/#/my/application/app-list' target='_blank'>open.agiso.com</a>上查看)
|
||||||
|
|
||||||
|
### .NET加密示例:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
var encryptCards = AES.EncryptAes(cards, appSecret);
|
||||||
|
|
||||||
|
//加密后的数据
|
||||||
|
encryptCards = "34APdvSJhWfr5wnzE4YzxAsI0gfI6pI/Njrj1UQMHJrZ8Dq9EWsDnln0pp08gn4KES4iQu9/f0Y8UMN4dGdSK67oi0Bc12/V8hrNxqoC3Lk=";
|
||||||
|
|
||||||
|
public class AES
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取Aes32位密钥
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">Aes密钥字符串</param>
|
||||||
|
/// <returns>Aes32位密钥</returns>
|
||||||
|
static byte[] GetAesKey(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException("key", "Aes密钥不能为空");
|
||||||
|
}
|
||||||
|
if (key.Length != 32)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException("key", "Aes密钥长度必须是32个字符");
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetBytes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aes加密
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">源字符串</param>
|
||||||
|
/// <param name="key">aes密钥</param>
|
||||||
|
/// <returns>加密后的字符串</returns>
|
||||||
|
public static string EncryptAes(string source, string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider())
|
||||||
|
{
|
||||||
|
aesProvider.Key = GetAesKey(key);
|
||||||
|
aesProvider.Mode = CipherMode.ECB;
|
||||||
|
aesProvider.Padding = PaddingMode.PKCS7;
|
||||||
|
using (ICryptoTransform cryptoTransform = aesProvider.CreateEncryptor())
|
||||||
|
{
|
||||||
|
byte[] inputBuffers = Encoding.UTF8.GetBytes(source);
|
||||||
|
byte[] results = cryptoTransform.TransformFinalBlock(inputBuffers, 0, inputBuffers.Length);
|
||||||
|
return Convert.ToBase64String(results, 0, results.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Java示例
|
||||||
|
|
||||||
|
```java
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public class Main {
|
||||||
|
static {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* AES/ECB/PKCS7Padding 加密
|
||||||
|
*
|
||||||
|
* @param plainText 明文字符串
|
||||||
|
* @param secretKey 密钥(32 字符)
|
||||||
|
* @return 加密后的 Base64 字符串
|
||||||
|
* @throws Exception 加密过程中可能抛出的异常
|
||||||
|
*/
|
||||||
|
public static String encrypt(String plainText, String secretKey) throws Exception {
|
||||||
|
if (secretKey.length() != 32) {
|
||||||
|
throw new IllegalArgumentException("密钥长度必须为 32 个字符");
|
||||||
|
}
|
||||||
|
byte[] keyBytes = secretKey.getBytes("UTF-8");
|
||||||
|
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||||
|
|
||||||
|
// 打印密钥信息
|
||||||
|
System.out.println("使用的密钥 (Base64): " + Base64.getEncoder().encodeToString(keyBytes));
|
||||||
|
System.out.println("密钥长度 (字节): " + keyBytes.length);
|
||||||
|
|
||||||
|
// 创建加密实例,使用 AES/ECB/PKCS7Padding 模式
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding");
|
||||||
|
System.out.println("使用的加密提供者: " + cipher.getProvider());
|
||||||
|
|
||||||
|
// 初始化加密模式
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
|
||||||
|
|
||||||
|
// 执行加密并返回 Base64 编码结果
|
||||||
|
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes("UTF-8"));
|
||||||
|
return Base64.getEncoder().encodeToString(encryptedBytes);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String plainText = "[{\"cardNo\":\"10001\",\"cardPwd\":\"123456\",\"expireTime\":\"2023-06-19 17:16:01\"}]";
|
||||||
|
String secretKey = "0a091b3aa4324435aab703142518a8f7";
|
||||||
|
String encrypted = encrypt(plainText, secretKey);
|
||||||
|
System.out.println("最终加密结果: " + encrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user