diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index 017ee19a..83406bf5 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -69,6 +69,15 @@ module.exports = { shops: {}, }, }, + ninetyone: { + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', + timestampToleranceSeconds: 600, + cardsEncoding: 'aes-256-ecb-base64', + }, khhao: { baseUrl: 'https://admin.khhao.com', timeoutMs: 5000, diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index 019628ea..d5896f03 100644 --- a/apps/backend/data/khhao-sync-state.json +++ b/apps/backend/data/khhao-sync-state.json @@ -1,13 +1,13 @@ { "syncFromCreatedAt": "2026-05-02T10:53:28.550Z", - "lastRunStartedAt": "2026-05-04T13:32:11.217Z", - "lastRunFinishedAt": "2026-05-04T13:32:11.824Z", + "lastRunStartedAt": "2026-05-04T14:03:19.832Z", + "lastRunFinishedAt": "2026-05-04T14:03:20.530Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, "syncedCount": 0, "ignoredCount": 40, - "lastOrderCreatedAt": "2026-05-04T13:32:06.000Z", + "lastOrderCreatedAt": "2026-05-04T14:02:50.000Z", "watchMode": "normal", "watchOrders": [] } diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 9e8f7caf..6933e396 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -235,6 +235,41 @@ function applyEnvOverrides(baseConfig) { 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() if (khhaoBaseUrl) { nextConfig.platforms.khhao.baseUrl = khhaoBaseUrl diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index fd92a0a3..a33daa28 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -5,6 +5,7 @@ import { runtimeConfig } from './config/runtime.js' import { runDatabaseMigrations } from './db/migrate.js' import adminRouter from './routes/admin.js' import claimsRouter from './routes/claims.js' +import open91Router from './routes/open-91.js' import webhooksRouter from './routes/webhooks.js' import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-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/open/91', open91Router) app.use('/api/v1/webhooks', webhooksRouter) app.use('/api/v1/claim', claimsRouter) app.use('/api/v1/admin', adminRouter) diff --git a/apps/backend/src/routes/open-91.js b/apps/backend/src/routes/open-91.js new file mode 100644 index 00000000..86fb9e66 --- /dev/null +++ b/apps/backend/src/routes/open-91.js @@ -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 diff --git a/apps/backend/src/services/open-91/order-create-service.js b/apps/backend/src/services/open-91/order-create-service.js new file mode 100644 index 00000000..a3a4105b --- /dev/null +++ b/apps/backend/src/services/open-91/order-create-service.js @@ -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: '', + }) +} diff --git a/apps/backend/src/services/open-91/order-query-service.js b/apps/backend/src/services/open-91/order-query-service.js new file mode 100644 index 00000000..b5b09788 --- /dev/null +++ b/apps/backend/src/services/open-91/order-query-service.js @@ -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), + }) +} diff --git a/apps/backend/src/services/open-91/shared.js b/apps/backend/src/services/open-91/shared.js new file mode 100644 index 00000000..1e56e02a --- /dev/null +++ b/apps/backend/src/services/open-91/shared.js @@ -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) +} diff --git a/apps/backend/src/services/open-91/shared.test.js b/apps/backend/src/services/open-91/shared.test.js new file mode 100644 index 00000000..c2e8c1ce --- /dev/null +++ b/apps/backend/src/services/open-91/shared.test.js @@ -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', + }, + ) +}) diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js index b1aab8db..1611ef0e 100644 --- a/apps/backend/src/types/runtime-config.js +++ b/apps/backend/src/types/runtime-config.js @@ -89,6 +89,15 @@ export {} * shops: AgisoMessagingShopsConfig * } * } + * ninetyone: { + * userId: string + * secret: string + * version: string + * shopId: string + * shopName: string + * timestampToleranceSeconds: number + * cardsEncoding: string + * } * khhao: { * baseUrl: string * timeoutMs: number diff --git a/docs/91卡券/6.开发设计.md b/docs/91卡券/6.开发设计.md new file mode 100644 index 00000000..6d5d0093 --- /dev/null +++ b/docs/91卡券/6.开发设计.md @@ -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` 之外的卡密加密算法; +- 不新增专门的后台管理页面。 diff --git a/docs/91卡券/卡密加密说明.md b/docs/91卡券/卡密加密说明.md new file mode 100644 index 00000000..54caf5be --- /dev/null +++ b/docs/91卡券/卡密加密说明.md @@ -0,0 +1,124 @@ +### 简要描述: +- AES/加密模式ECB/填充PKCS7Padding/数据块128位 注意:加密密钥为开放平台的AppSecret(从open.agiso.com上查看) + +**假设cards为:** +``` +[{"cardNo":"10001","cardPwd":"123456","expireTime":"2023-06-19 17:16:01"}] +``` + +**假设开放平台的appSecret为:** 0a091b3aa4324435aab703142518a8f7(从open.agiso.com上查看) + +### .NET加密示例: + +```csharp + +var encryptCards = AES.EncryptAes(cards, appSecret); + +//加密后的数据 +encryptCards = "34APdvSJhWfr5wnzE4YzxAsI0gfI6pI/Njrj1UQMHJrZ8Dq9EWsDnln0pp08gn4KES4iQu9/f0Y8UMN4dGdSK67oi0Bc12/V8hrNxqoC3Lk="; + +public class AES +{ + /// + /// 获取Aes32位密钥 + /// + /// Aes密钥字符串 + /// Aes32位密钥 + 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); + } + + + /// + /// Aes加密 + /// + /// 源字符串 + /// aes密钥 + /// 加密后的字符串 + 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); + } +} +``` \ No newline at end of file