简化履约匹配并支持mock订单测试
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
.env copy
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"format": "prettier --write .",
|
||||
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { loadEnvFiles } from '../src/config/runtime-env.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const secret = readEnv('KAQUAN91_SECRET')
|
||||
const version = readEnv('KAQUAN91_VERSION') || '1.0'
|
||||
const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '')
|
||||
const mode = String(args.mode || 'create').trim()
|
||||
|
||||
if (!secret || secret.length !== 32) {
|
||||
console.error('缺少 32 位 91 密钥:请配置 KAQUAN91_SECRET。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!['create', 'query'].includes(mode)) {
|
||||
console.error('mode 只能是 create 或 query。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||
const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000))
|
||||
|
||||
const payload = mode === 'query'
|
||||
? {
|
||||
orderNo,
|
||||
timestamp,
|
||||
version,
|
||||
}
|
||||
: {
|
||||
buyNum: normalizePositiveInteger(args.buyNum, 1),
|
||||
callbackUrl: String(args.callbackUrl || ''),
|
||||
maxAmount: String(args.maxAmount || '0.01'),
|
||||
orderNo,
|
||||
productNo: String(args.productNo || '套装-浪漫天命').trim(),
|
||||
timestamp,
|
||||
version,
|
||||
}
|
||||
|
||||
const body = {
|
||||
...payload,
|
||||
sign: signOpen91Payload(payload, secret),
|
||||
}
|
||||
|
||||
const endpoint = `${baseUrl}/api/v1/open/91/orders/${mode}`
|
||||
|
||||
console.log(`POST ${endpoint}`)
|
||||
console.log(JSON.stringify(maskPayload(body), null, 2))
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch((error) => {
|
||||
console.error('请求 91 mock 接口失败,请确认后端入口可访问。')
|
||||
console.error(`当前地址:${endpoint}`)
|
||||
console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。')
|
||||
throw error
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
console.log(`HTTP ${response.status}`)
|
||||
|
||||
try {
|
||||
console.log(JSON.stringify(JSON.parse(responseText), null, 2))
|
||||
} catch {
|
||||
console.log(responseText)
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: JsonObject = {}
|
||||
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
const value = normalized.slice(separatorIndex + 1).trim()
|
||||
if (key) {
|
||||
parsed[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readEnv(...keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = String(process.env[key] || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function signOpen91Payload(payload: JsonObject, currentSecret: string) {
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(buildOpen91SignSource(payload, currentSecret), 'utf8')
|
||||
.digest('hex')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function buildOpen91SignSource(payload: JsonObject, currentSecret: string) {
|
||||
const queryString = Object.entries(payload)
|
||||
.filter(([key]) => key !== 'sign')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
|
||||
.join('&')
|
||||
|
||||
return `${currentSecret}${queryString}${currentSecret}`
|
||||
}
|
||||
|
||||
function stringifySignValue(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 normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function maskPayload(payload: JsonObject) {
|
||||
return {
|
||||
...payload,
|
||||
sign: String(payload.sign || '').replace(/^(.{6}).+(.{4})$/, '$1***$2'),
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
模拟 91 卡券请求:
|
||||
|
||||
npm run mock:open91 -- --productNo=套装-浪漫天命
|
||||
npm run mock:open91 -- --productNo=荣耀勋章礼包(30个) --maxAmount=300 --buyNum=1
|
||||
npm run mock:open91 -- --mode=query --orderNo=MOCK911779000000000
|
||||
|
||||
参数:
|
||||
--baseUrl 后端地址,默认 http://127.0.0.1
|
||||
--mode create 或 query,默认 create
|
||||
--orderNo 91 订单号,默认自动生成
|
||||
--productNo 91 商品名,默认 套装-浪漫天命
|
||||
--buyNum 购买数量,默认 1
|
||||
--maxAmount 最大金额/正常金额,默认 0.01
|
||||
--callbackUrl 回调地址,默认空
|
||||
`)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getKuaishouCloudFulfillmentFilePath,
|
||||
saveKuaishouCloudFulfillmentConfig,
|
||||
} from '../../order/kuaishou-cloud-fulfillment-config-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { mapAdminKuaishouCloudFulfillmentSource } from './cloudtentacles/mappers.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
@@ -26,8 +25,6 @@ export async function updateAdminKuaishouCloudFulfillmentConfig(
|
||||
items: Array.isArray(payload.items) ? payload.items : [],
|
||||
})
|
||||
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getKuaishouCloudFulfillmentFilePath(),
|
||||
source: mapAdminKuaishouCloudFulfillmentSource(saved),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCloudtentaclesBindInfo,
|
||||
} from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { consumeKuaishouEticket } from '../../platforms/kuaishou-eticket/consume-service.js'
|
||||
import { isKuaishouEticketMockTicketCode } from '../../platforms/kuaishou-eticket/mock-ticket-service.js'
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
@@ -489,6 +490,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
|
||||
const ticketCode = String(flow.ticket.code || '').trim()
|
||||
const shopId = String(flow.consume.shopId || order?.shop_id || '').trim()
|
||||
const shopName = String(flow.consume.shopName || order?.shop_name || '').trim()
|
||||
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode)
|
||||
const eticketSource = getKuaishouEticketSourceConfig()
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
@@ -507,6 +509,15 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
|
||||
} else if (!ticketCode) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销'
|
||||
} else if (mockTicketCode) {
|
||||
const consumeResult = await consumeKuaishouEticket({
|
||||
eTicketId: ticketCode,
|
||||
oid: String(flow.ticket.oid || '').trim(),
|
||||
formToken: String(flow.ticket.formToken || '').trim(),
|
||||
})
|
||||
consumeStatus = consumeResult.consumed ? 'success' : 'failed'
|
||||
consumedAt = consumeResult.consumed ? now : null
|
||||
consumeErrorMessage = String(consumeResult.errorMessage || '').trim()
|
||||
} else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销'
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
getFulfillmentProfileByKey,
|
||||
listFulfillmentProfileRequirements,
|
||||
replaceFulfillmentProfileRequirements,
|
||||
upsertFulfillmentProfile,
|
||||
upsertSkuFulfillmentBinding,
|
||||
type FulfillmentProfileRequirementInput,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import {
|
||||
getKuaishouCloudFulfillmentConfig,
|
||||
mapKuaishouCloudFulfillmentItemsToBindings,
|
||||
} from '../order/kuaishou-cloud-fulfillment-config-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
@@ -48,8 +39,7 @@ const CORE_PROFILES: CoreProfile[] = [
|
||||
]
|
||||
|
||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
const profileMap = await ensureCoreProfiles()
|
||||
await syncConfiguredFulfillmentBindings(profileMap)
|
||||
await ensureCoreProfiles()
|
||||
}
|
||||
|
||||
async function ensureCoreProfiles() {
|
||||
@@ -82,82 +72,3 @@ async function ensureCoreProfiles() {
|
||||
|
||||
return profileMap
|
||||
}
|
||||
|
||||
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
|
||||
const timestamp = nowIso()
|
||||
const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
|
||||
|
||||
await query('DELETE FROM product_match_rules')
|
||||
await query('DELETE FROM sku_fulfillment_bindings')
|
||||
|
||||
for (const binding of bindingsToApply) {
|
||||
const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey)
|
||||
if (!profile) {
|
||||
continue
|
||||
}
|
||||
|
||||
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
||||
const matchShopIds = resolveBindingMatchShopIds(binding)
|
||||
const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
|
||||
for (const matchShopId of matchShopIds) {
|
||||
await upsertSkuFulfillmentBinding({
|
||||
skuCode: binding.skuCode,
|
||||
provider: binding.provider,
|
||||
platform: binding.platform,
|
||||
shopId: matchShopId,
|
||||
profileId: profile.id,
|
||||
enabled: binding.enabled,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify(bindingConfig),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
if (!externalSkuName && !externalItemId && !externalSkuCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
await upsertProductMatchRule({
|
||||
provider: binding.provider,
|
||||
platform: binding.platform,
|
||||
shopId: matchShopId,
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized: normalizeProductName(externalSkuName),
|
||||
resolvedSkuCode: binding.skuCode,
|
||||
enabled: binding.enabled,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify(match.config || {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBindingMatchShopIds(binding: JsonObject = {}) {
|
||||
return [String(binding.shopId || '').trim()]
|
||||
}
|
||||
|
||||
function resolveBindingRuntimeConfig(binding: JsonObject = {}) {
|
||||
const baseConfig = isPlainObject(binding.config) ? { ...binding.config } : {}
|
||||
|
||||
if (String(binding.provider || '').trim() === '91kaquan' && String(binding.platform || '').trim() === 'kuaishou') {
|
||||
baseConfig.kuaishouShop = {
|
||||
...(isPlainObject(baseConfig.kuaishouShop) ? baseConfig.kuaishouShop : {}),
|
||||
shopId: String(binding.shopId || '').trim(),
|
||||
shopName: String(binding.shopName || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
listKuaishouEticketShopConfigs,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
type KuaishouEticketShopConfig,
|
||||
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
||||
import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js'
|
||||
import { isKuaishouEticketMockTicketCode } from '../platforms/kuaishou-eticket/mock-ticket-service.js'
|
||||
import {
|
||||
dispatchKuaishouCloudFulfillmentTask,
|
||||
maskCode,
|
||||
@@ -48,24 +51,28 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
})
|
||||
}
|
||||
|
||||
const shopId = String(flow.consume.shopId || context.order.shop_id || '').trim()
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
shopName: String(context.order.shop_name || '').trim(),
|
||||
})
|
||||
if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) {
|
||||
throw createHttpError('这笔订单对应的快手小店还没有配置可用 Cookie,请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
|
||||
})
|
||||
}
|
||||
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode)
|
||||
let shopId = String(flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : '')).trim()
|
||||
let shopName = String(flow.consume.shopName || context.order.shop_name || '').trim()
|
||||
let detailResult
|
||||
|
||||
const eticketSource = getKuaishouEticketSourceConfig()
|
||||
const detailResult = await queryKuaishouEticketConsumeDetail({
|
||||
baseUrl: eticketSource.baseUrl,
|
||||
cookie: shopConfig.cookie,
|
||||
if (mockTicketCode) {
|
||||
detailResult = await queryKuaishouEticketConsumeDetail({
|
||||
eTicketId: ticketCode,
|
||||
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code,
|
||||
})
|
||||
} else {
|
||||
const matched = await queryKuaishouEticketConsumeDetailWithShopFallback({
|
||||
ticketCode,
|
||||
shopId,
|
||||
shopName,
|
||||
fallbackShopName: String(context.order.shop_name || '').trim(),
|
||||
})
|
||||
|
||||
detailResult = matched.detailResult
|
||||
shopId = matched.shopConfig.shopId || shopId
|
||||
shopName = matched.shopConfig.kshopName || shopName
|
||||
}
|
||||
|
||||
if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) {
|
||||
throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', {
|
||||
@@ -130,8 +137,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', {
|
||||
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
|
||||
shopId,
|
||||
shopName: String(context.order.shop_name || '').trim(),
|
||||
shopName,
|
||||
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
|
||||
mock: mockTicketCode,
|
||||
}, now)
|
||||
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
@@ -190,6 +198,133 @@ function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject
|
||||
}
|
||||
}
|
||||
|
||||
async function queryKuaishouEticketConsumeDetailWithShopFallback({
|
||||
ticketCode,
|
||||
shopId,
|
||||
shopName,
|
||||
fallbackShopName = '',
|
||||
}: {
|
||||
ticketCode: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
fallbackShopName?: string
|
||||
}) {
|
||||
const eticketSource = getKuaishouEticketSourceConfig()
|
||||
const shopConfigs = resolveKuaishouEticketDetailCandidateShops({
|
||||
shopId,
|
||||
shopName,
|
||||
fallbackShopName,
|
||||
})
|
||||
|
||||
if (shopConfigs.length === 0) {
|
||||
throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
|
||||
})
|
||||
}
|
||||
|
||||
let lastResult: JsonObject | null = null
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const shopConfig of shopConfigs) {
|
||||
try {
|
||||
const detailResult = await queryKuaishouEticketConsumeDetail({
|
||||
baseUrl: eticketSource.baseUrl,
|
||||
cookie: shopConfig.cookie,
|
||||
eTicketId: ticketCode,
|
||||
})
|
||||
|
||||
lastResult = detailResult
|
||||
if (detailResult.ok || detailResult.alreadyConsumed) {
|
||||
return {
|
||||
shopConfig,
|
||||
detailResult,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
const fallbackShopConfig = shopConfigs[0]
|
||||
if (!fallbackShopConfig) {
|
||||
throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
shopConfig: fallbackShopConfig,
|
||||
detailResult: lastResult,
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError(lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_ticket_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
function resolveKuaishouEticketDetailCandidateShops({
|
||||
shopId,
|
||||
shopName,
|
||||
fallbackShopName = '',
|
||||
}: {
|
||||
shopId: string
|
||||
shopName: string
|
||||
fallbackShopName?: string
|
||||
}) {
|
||||
const candidates: KuaishouEticketShopConfig[] = []
|
||||
const configuredShop = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
shopName: shopName || fallbackShopName,
|
||||
})
|
||||
|
||||
if (isUsableKuaishouEticketShopConfig(configuredShop)) {
|
||||
candidates.push(configuredShop)
|
||||
}
|
||||
|
||||
for (const shopConfig of listKuaishouEticketShopConfigs()) {
|
||||
if (!isUsableKuaishouEticketShopConfig(shopConfig)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (candidates.some((item) => isSameKuaishouEticketShopConfig(item, shopConfig))) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.push(shopConfig)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function isUsableKuaishouEticketShopConfig(
|
||||
shopConfig: KuaishouEticketShopConfig | null | undefined,
|
||||
): shopConfig is KuaishouEticketShopConfig {
|
||||
return Boolean(
|
||||
shopConfig &&
|
||||
shopConfig.enabled !== false &&
|
||||
String(shopConfig.cookie || '').trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function isSameKuaishouEticketShopConfig(
|
||||
left: KuaishouEticketShopConfig,
|
||||
right: KuaishouEticketShopConfig,
|
||||
) {
|
||||
const leftShopId = String(left.shopId || '').trim()
|
||||
const rightShopId = String(right.shopId || '').trim()
|
||||
|
||||
if (leftShopId && rightShopId) {
|
||||
return leftShopId === rightShopId
|
||||
}
|
||||
|
||||
return String(left.kshopName || '').trim() === String(right.kshopName || '').trim()
|
||||
}
|
||||
|
||||
export async function confirmKuaishouCloudClaimRole(token: unknown) {
|
||||
const context = await getClaimContext(token)
|
||||
const now = nowIso()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-noti
|
||||
import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js";
|
||||
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
@@ -305,6 +306,7 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
const shopName = String(
|
||||
flow.consume.shopName || order?.shop_name || ""
|
||||
).trim();
|
||||
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode);
|
||||
const eticketSource = getKuaishouEticketSourceConfig();
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
@@ -324,6 +326,15 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
} else if (!ticketCode) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销";
|
||||
} else if (mockTicketCode) {
|
||||
const consumeResult = await consumeKuaishouEticket({
|
||||
eTicketId: ticketCode,
|
||||
oid: String(flow.ticket.oid || "").trim(),
|
||||
formToken: String(flow.ticket.formToken || "").trim(),
|
||||
});
|
||||
consumeStatus = consumeResult.consumed ? "success" : "failed";
|
||||
consumedAt = consumeResult.consumed ? now : null;
|
||||
consumeErrorMessage = String(consumeResult.errorMessage || "").trim();
|
||||
} else if (
|
||||
!shopConfig ||
|
||||
shopConfig.enabled === false ||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
normalizeCloudtentaclesMatchName,
|
||||
resolveCloudtentaclesSkuByProductName,
|
||||
} from './cloudtentacles-name-match-service.js'
|
||||
|
||||
test('normalizeCloudtentaclesMatchName normalizes punctuation and spaces', () => {
|
||||
assert.equal(
|
||||
normalizeCloudtentaclesMatchName(' 荣耀勋章礼包(30个) '),
|
||||
normalizeCloudtentaclesMatchName('荣耀勋章礼包(30个)'),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sources', async () => {
|
||||
const result = await resolveCloudtentaclesSkuByProductName('荣耀勋章礼包(30个)', {
|
||||
listCloudtentaclesSources: () => ({
|
||||
enabled: true,
|
||||
sources: [
|
||||
{
|
||||
key: 'account-a',
|
||||
label: '账号 A',
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCloudtentaclesSessionStateByKey: () => ({
|
||||
token: 'token-a',
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
username: 'user-a',
|
||||
phone: '',
|
||||
loggedInAt: '2026-05-27T12:00:00.000Z',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
}),
|
||||
listCloudtentaclesSku: async () => ({
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
itemCount: 1,
|
||||
rawItems: [],
|
||||
items: [
|
||||
{
|
||||
id: 74,
|
||||
name: '荣耀勋章礼包(30个)',
|
||||
inventory: 13837,
|
||||
price: 300,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result?.cloudSkuId, 74)
|
||||
assert.equal(result?.cloudSkuName, '荣耀勋章礼包(30个)')
|
||||
assert.deepEqual(result?.cloudSourceKeys, ['account-a'])
|
||||
assert.equal(result?.matchMode, 'cloudtentacles_name')
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
listCloudtentaclesSources,
|
||||
} from '../platforms/cloudtentacles/source-config-service.js'
|
||||
import {
|
||||
getCloudtentaclesSessionStateByKey,
|
||||
} from '../platforms/cloudtentacles/session-state-service.js'
|
||||
import {
|
||||
listCloudtentaclesSku,
|
||||
} from '../platforms/cloudtentacles/catalog-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
type CloudtentaclesSource = {
|
||||
key?: string
|
||||
label?: string
|
||||
enabled?: boolean
|
||||
baseUrl?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}
|
||||
|
||||
type CloudtentaclesSession = {
|
||||
token?: string
|
||||
baseUrl?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}
|
||||
|
||||
type CloudtentaclesSku = {
|
||||
id?: number
|
||||
name?: string
|
||||
inventory?: number
|
||||
price?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type CloudtentaclesNameMatchDeps = {
|
||||
listCloudtentaclesSources?: typeof listCloudtentaclesSources
|
||||
getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey
|
||||
listCloudtentaclesSku?: typeof listCloudtentaclesSku
|
||||
}
|
||||
|
||||
export type CloudtentaclesNameMatchResult = {
|
||||
matchMode: 'cloudtentacles_name'
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
cloudSkuPrice: number
|
||||
cloudSkuInventory: number
|
||||
cloudSourceKeys: string[]
|
||||
resolvedSourceKey: string
|
||||
skuSnapshot: JsonObject
|
||||
}
|
||||
|
||||
export async function resolveCloudtentaclesSkuByProductName(
|
||||
productName: unknown,
|
||||
deps: CloudtentaclesNameMatchDeps = {},
|
||||
): Promise<CloudtentaclesNameMatchResult | null> {
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources
|
||||
const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
|
||||
const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku
|
||||
const sourcesConfig = listSources()
|
||||
|
||||
if (sourcesConfig.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : [])
|
||||
.map((source: CloudtentaclesSource) => buildCloudtentaclesSourceContext(source, getSessionByKey))
|
||||
.filter((context): context is NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>> => Boolean(context))
|
||||
|
||||
if (sourceContexts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey)
|
||||
|
||||
for (const context of sourceContexts) {
|
||||
try {
|
||||
const skuList = await listSku(context)
|
||||
const matchedSku = findCloudtentaclesSkuByName(
|
||||
Array.isArray(skuList.items) ? skuList.items : [],
|
||||
productName,
|
||||
)
|
||||
|
||||
if (!matchedSku) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cloudSkuId = Number(matchedSku.id || 0)
|
||||
const cloudSkuName = String(matchedSku.name || '').trim()
|
||||
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
||||
continue
|
||||
}
|
||||
|
||||
return {
|
||||
matchMode: 'cloudtentacles_name',
|
||||
productName: String(productName || '').trim(),
|
||||
normalizedProductName,
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
cloudSkuPrice: Number(matchedSku.price || 0) || 0,
|
||||
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
||||
cloudSourceKeys,
|
||||
resolvedSourceKey: context.sourceKey,
|
||||
skuSnapshot: { ...matchedSku },
|
||||
}
|
||||
} catch {
|
||||
// 当前账号不可用时继续尝试下一个已登录账号。
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function buildCloudtentaclesSourceContext(
|
||||
source: CloudtentaclesSource,
|
||||
getSessionByKey: typeof getCloudtentaclesSessionStateByKey,
|
||||
) {
|
||||
const sourceKey = String(source.key || '').trim()
|
||||
if (!sourceKey || source.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = getSessionByKey(sourceKey) as CloudtentaclesSession | null
|
||||
const token = String(session?.token || '').trim()
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(),
|
||||
token,
|
||||
deviceId: String(session?.deviceId || source.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(session?.deviceType ?? source.deviceType ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: unknown) {
|
||||
const rawName = String(productName || '').trim()
|
||||
const normalizedName = normalizeCloudtentaclesMatchName(rawName)
|
||||
|
||||
return (
|
||||
items.find((item) => String(item.name || '').trim() === rawName) ||
|
||||
items.find((item) => normalizeCloudtentaclesMatchName(item.name) === normalizedName) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -113,3 +113,85 @@ test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrderWithDeps creates kuaishou cloud task from cloudtentacles name match snapshot', async () => {
|
||||
const createdTasks = []
|
||||
const updates = []
|
||||
|
||||
const dynamicOrderItems = [
|
||||
{
|
||||
id: 21,
|
||||
order_id: 10,
|
||||
sku_code: '套装-浪漫天命',
|
||||
sku_name: '套装-浪漫天命',
|
||||
quantity: 1,
|
||||
spec_json: '{}',
|
||||
item_snapshot_json: JSON.stringify({
|
||||
matchMode: 'cloudtentacles_name',
|
||||
cloudtentacles: {
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '套装-浪漫天命',
|
||||
cloudSourceKeys: ['account-a', 'account-b'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, dynamicOrderItems, {
|
||||
listTasksByOrderId: async () => [],
|
||||
resolveFulfillmentBinding: async () => null,
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 2,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
name: '快手 cloud 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
config_json: '{}',
|
||||
}),
|
||||
createTask: async (input) => {
|
||||
createdTasks.push(input)
|
||||
return {
|
||||
id: 31,
|
||||
order_id: input.orderId,
|
||||
order_item_id: input.orderItemId,
|
||||
task_status: input.taskStatus,
|
||||
executor_key: input.executorKey,
|
||||
requires_claim: input.requiresClaim,
|
||||
claim_token: '',
|
||||
primary_claim_token_id: null,
|
||||
last_error: '',
|
||||
context_json: input.contextJson,
|
||||
}
|
||||
},
|
||||
createTaskClaimToken: async () => ({
|
||||
token: 'claim-token',
|
||||
expired_at: '2026-04-15T12:00:00.000Z',
|
||||
}),
|
||||
updateTask: async (taskId, patch) => {
|
||||
updates.push({ taskId, patch })
|
||||
return { id: taskId, ...patch }
|
||||
},
|
||||
nowIso: () => '2026-04-14T12:03:00.000Z',
|
||||
randomId: () => 'DT-DYNAMIC',
|
||||
})
|
||||
|
||||
assert.equal(createdTasks.length, 1)
|
||||
assert.equal(createdTasks[0].executorKey, 'kuaishou_ct_assisted')
|
||||
assert.equal(createdTasks[0].profileId, 2)
|
||||
assert.equal(createdTasks[0].taskStatus, 'pending_binding_prepare')
|
||||
|
||||
const context = JSON.parse(createdTasks[0].contextJson)
|
||||
assert.equal(context.kuaishouCloudFulfillment.binding.skuId, 28)
|
||||
assert.equal(context.kuaishouCloudFulfillment.binding.skuName, '套装-浪漫天命')
|
||||
assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, ['account-a', 'account-b'])
|
||||
assert.deepEqual(context.kuaishouCloudFulfillment.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '套装-浪漫天命',
|
||||
quantity: 1,
|
||||
},
|
||||
])
|
||||
assert.equal(result[0]?.claim_token, 'claim-token')
|
||||
assert.equal(updates[0]?.patch.claim_token, 'claim-token')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
|
||||
import {
|
||||
getFulfillmentProfileByKey,
|
||||
resolveFulfillmentBinding,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
@@ -44,6 +45,7 @@ type DeliveryTaskDeps = {
|
||||
platform?: string
|
||||
shopId?: string
|
||||
}) => Promise<FulfillmentBindingLike | null>
|
||||
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | null>
|
||||
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
||||
notifyTaskAutoManualReview?: (payload: {
|
||||
task: unknown
|
||||
@@ -83,6 +85,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
listTasksByOrderId: listTasks = listTasksByOrderId,
|
||||
updateTask: updateDeliveryTask = updateTask,
|
||||
resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding,
|
||||
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
|
||||
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
||||
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
||||
nowIso: getNowIso = nowIso,
|
||||
@@ -122,10 +125,11 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
shopId: order.shop_id,
|
||||
})
|
||||
|
||||
if (!binding) {
|
||||
const profile = binding || await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
|
||||
|
||||
if (!profile) {
|
||||
continue
|
||||
}
|
||||
const profile = binding
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||
@@ -391,6 +395,62 @@ function parseJsonObject(value: unknown): JsonObject {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDynamicCloudtentaclesProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const cloudtentacles = parseJsonObject(snapshot.cloudtentacles)
|
||||
const cloudSkuId = Number(cloudtentacles.cloudSkuId || 0) || 0
|
||||
const cloudSkuName = String(cloudtentacles.cloudSkuName || item.sku_name || '').trim()
|
||||
const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys)
|
||||
|
||||
if (!cloudSkuId || !cloudSkuName || cloudSourceKeys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await getProfileByKey('kuaishou_ct_assisted')
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(),
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
config_json: {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: `cloudtentacles_name:${cloudSkuId}`,
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys,
|
||||
skuId: cloudSkuId,
|
||||
skuName: cloudSkuName,
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: true,
|
||||
},
|
||||
kuaishouConsume: {
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
autoConsumeAfterDispatch: false,
|
||||
},
|
||||
notes: '91卡券商品名自动匹配 cloudtentacles 商品',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolveFulfillmentBinding } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||
import {
|
||||
resolveCloudtentaclesSkuByProductName,
|
||||
type CloudtentaclesNameMatchResult,
|
||||
} from './cloudtentacles-name-match-service.js'
|
||||
|
||||
export type FulfillmentItem = {
|
||||
itemId?: string
|
||||
@@ -34,6 +38,7 @@ type FulfillmentItemCandidate = {
|
||||
matchedRule: Awaited<ReturnType<typeof resolveProductMatchRule>>
|
||||
resolvedSkuCode: string
|
||||
binding: Awaited<ReturnType<typeof resolveFulfillmentBinding>>
|
||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||
isConfigured: boolean
|
||||
}
|
||||
|
||||
@@ -74,14 +79,17 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuNameNormalized,
|
||||
matchedRule,
|
||||
binding,
|
||||
cloudtentaclesNameMatch,
|
||||
} = candidate
|
||||
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
matchedRule?.resolved_sku_code,
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
const resolvedSkuName = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
readConfigValue(matchedRule?.config_json, 'resolvedSkuName'),
|
||||
readConfigValue(matchedRule?.config_json, 'internalProductName'),
|
||||
item.skuName,
|
||||
@@ -99,6 +107,21 @@ export async function resolveOrderItemForFulfillment({
|
||||
matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(),
|
||||
matchedFulfillmentBindingId: binding ? Number(binding.id) : null,
|
||||
matchedFulfillmentProfileKey: String(binding?.profile_key || '').trim(),
|
||||
matchMode: cloudtentaclesNameMatch?.matchMode || String(matchedRule?.matched_by || '').trim(),
|
||||
cloudtentacles: cloudtentaclesNameMatch
|
||||
? {
|
||||
matchMode: cloudtentaclesNameMatch.matchMode,
|
||||
productName: cloudtentaclesNameMatch.productName,
|
||||
normalizedProductName: cloudtentaclesNameMatch.normalizedProductName,
|
||||
cloudSkuId: cloudtentaclesNameMatch.cloudSkuId,
|
||||
cloudSkuName: cloudtentaclesNameMatch.cloudSkuName,
|
||||
cloudSkuPrice: cloudtentaclesNameMatch.cloudSkuPrice,
|
||||
cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory,
|
||||
cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys,
|
||||
resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey,
|
||||
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
|
||||
}
|
||||
: null,
|
||||
isConfigured: candidate.isConfigured,
|
||||
}
|
||||
|
||||
@@ -182,6 +205,27 @@ async function resolveConfiguredItemCandidate({
|
||||
let matchedRule = null
|
||||
let binding = null
|
||||
|
||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
|
||||
return {
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized,
|
||||
matchedRule: null,
|
||||
resolvedSkuCode,
|
||||
binding: null,
|
||||
cloudtentaclesNameMatch,
|
||||
isConfigured: Boolean(cloudtentaclesNameMatch),
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidateShopId of shopIdCandidates) {
|
||||
const candidateRule = await resolveProductMatchRule({
|
||||
provider,
|
||||
@@ -228,6 +272,7 @@ async function resolveConfiguredItemCandidate({
|
||||
matchedRule,
|
||||
resolvedSkuCode,
|
||||
binding,
|
||||
cloudtentaclesNameMatch: null,
|
||||
isConfigured: Boolean(binding),
|
||||
}
|
||||
}
|
||||
@@ -268,3 +313,8 @@ function safeParseJson(rawValue: unknown): unknown {
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
|
||||
return String(provider || '').trim() === '91kaquan'
|
||||
&& String(platform || '').trim() === 'kuaishou'
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
consumeKuaishouEticket,
|
||||
mapKuaishouEticketDetailResult,
|
||||
normalizeKuaishouEticketApiResult,
|
||||
queryKuaishouEticketConsumeDetail,
|
||||
resolveKuaishouEticketConsumePayload,
|
||||
} from './consume-service.js'
|
||||
|
||||
@@ -70,3 +72,50 @@ test('resolveKuaishouEticketConsumePayload falls back to detail result', () => {
|
||||
formToken: '1777716306154',
|
||||
})
|
||||
})
|
||||
|
||||
test('queryKuaishouEticketConsumeDetail accepts mock ticket code outside production', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
try {
|
||||
const result = await queryKuaishouEticketConsumeDetail({
|
||||
eTicketId: 'MOCK-CLAIM-001',
|
||||
goodsTitle: '套装-浪漫天命',
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.eTicketId, 'MOCK-CLAIM-001')
|
||||
assert.equal(result.detail?.leftCount, 1)
|
||||
assert.equal(result.goods?.itemTitle, '套装-浪漫天命')
|
||||
} finally {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('consumeKuaishouEticket consumes mock ticket code outside production', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
try {
|
||||
const result = await consumeKuaishouEticket({
|
||||
eTicketId: 'MOCK-CLAIM-002',
|
||||
oid: 'MOCK-OID-002',
|
||||
formToken: 'MOCK-FORM-002',
|
||||
})
|
||||
|
||||
assert.equal(result.consumed, true)
|
||||
assert.equal(result.eTicketId, 'MOCK-CLAIM-002')
|
||||
assert.equal(result.oid, 'MOCK-OID-002')
|
||||
assert.equal(result.formToken, 'MOCK-FORM-002')
|
||||
} finally {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { kuaishouEticketRequest } from './http-client.js'
|
||||
import { resolveKuaishouEticketConfig } from './helpers.js'
|
||||
import {
|
||||
buildMockKuaishouEticketConsumeResult,
|
||||
buildMockKuaishouEticketDetailResult,
|
||||
isKuaishouEticketMockTicketCode,
|
||||
} from './mock-ticket-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -18,6 +23,14 @@ type KuaishouEticketDetailResult = {
|
||||
}
|
||||
|
||||
export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {}) {
|
||||
if (isKuaishouEticketMockTicketCode(payload.eTicketId)) {
|
||||
return buildMockKuaishouEticketDetailResult({
|
||||
baseUrl: String(payload.baseUrl || '').trim(),
|
||||
eTicketId: String(payload.eTicketId || '').trim(),
|
||||
goodsTitle: String(payload.goodsTitle || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const context = resolveKuaishouEticketActionContext(payload)
|
||||
const response = await kuaishouEticketRequest(context.detailPath, {
|
||||
baseUrl: context.baseUrl,
|
||||
@@ -35,6 +48,17 @@ export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {}
|
||||
}
|
||||
|
||||
export async function consumeKuaishouEticket(payload: JsonObject = {}) {
|
||||
if (isKuaishouEticketMockTicketCode(payload.eTicketId)) {
|
||||
return buildMockKuaishouEticketConsumeResult({
|
||||
baseUrl: String(payload.baseUrl || '').trim(),
|
||||
eTicketId: String(payload.eTicketId || '').trim(),
|
||||
oid: String(payload.oid || '').trim(),
|
||||
formToken: String(payload.formToken || '').trim(),
|
||||
num: payload.num,
|
||||
storeId: String(payload.storeId || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const context = resolveKuaishouEticketActionContext(payload)
|
||||
const detailResult = shouldResolveConsumeDetail(payload)
|
||||
? await queryKuaishouEticketConsumeDetail({
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { isProductionLike } from '../../../config/runtime-validation.js'
|
||||
|
||||
type RuntimeEnvironment = {
|
||||
NODE_ENV?: string
|
||||
}
|
||||
|
||||
type MockTicketDetailOptions = {
|
||||
eTicketId: string
|
||||
baseUrl?: string
|
||||
oid?: string
|
||||
formToken?: string
|
||||
leftCount?: number
|
||||
goodsTitle?: string
|
||||
}
|
||||
|
||||
type MockTicketConsumeOptions = {
|
||||
eTicketId: string
|
||||
baseUrl?: string
|
||||
oid?: string
|
||||
formToken?: string
|
||||
num?: number
|
||||
storeId?: string
|
||||
}
|
||||
|
||||
export function isKuaishouEticketMockTicketCode(
|
||||
ticketCode: unknown,
|
||||
env: RuntimeEnvironment = process.env,
|
||||
): boolean {
|
||||
const normalized = String(ticketCode || '').trim().toUpperCase()
|
||||
|
||||
if (!normalized || isProductionLike(env)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return normalized === 'MOCK' || normalized.startsWith('MOCK-') || normalized.startsWith('MOCK_')
|
||||
}
|
||||
|
||||
export function buildMockKuaishouEticketDetailResult(options: MockTicketDetailOptions) {
|
||||
const eTicketId = String(options.eTicketId || '').trim()
|
||||
const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim()
|
||||
const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim()
|
||||
const leftCount = normalizePositiveInteger(options.leftCount, 1)
|
||||
const goodsTitle = String(options.goodsTitle || 'MOCK 快手商品').trim()
|
||||
|
||||
return {
|
||||
baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(),
|
||||
eTicketId,
|
||||
ok: true,
|
||||
alreadyConsumed: false,
|
||||
result: 1,
|
||||
errorMessage: '',
|
||||
requestId: `mock-${Date.now()}`,
|
||||
serverTimestamp: new Date().toISOString(),
|
||||
detail: {
|
||||
uid: 'mock',
|
||||
fulfillDetailId: `MOCK-FULFILL-${eTicketId}`,
|
||||
sellerId: 'mock',
|
||||
formToken,
|
||||
validEndTime: '',
|
||||
validStartTime: '',
|
||||
leftReverseCount: 0,
|
||||
eTicketId,
|
||||
oid,
|
||||
totalCount: leftCount,
|
||||
leftCount,
|
||||
status: 'MOCK',
|
||||
},
|
||||
goods: {
|
||||
itemId: 'mock',
|
||||
itemPicUrl: '',
|
||||
itemTitle: goodsTitle,
|
||||
price: '',
|
||||
skuDesc: goodsTitle,
|
||||
skuId: 'mock',
|
||||
},
|
||||
raw: {
|
||||
mock: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMockKuaishouEticketConsumeResult(options: MockTicketConsumeOptions) {
|
||||
const eTicketId = String(options.eTicketId || '').trim()
|
||||
const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim()
|
||||
const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim()
|
||||
const num = normalizePositiveInteger(options.num, 1)
|
||||
|
||||
return {
|
||||
baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(),
|
||||
eTicketId,
|
||||
oid,
|
||||
formToken,
|
||||
num,
|
||||
storeId: String(options.storeId || '0').trim() || '0',
|
||||
ok: true,
|
||||
consumed: true,
|
||||
alreadyConsumed: false,
|
||||
result: 1,
|
||||
errorMessage: '',
|
||||
requestId: `mock-${Date.now()}`,
|
||||
serverTimestamp: new Date().toISOString(),
|
||||
detail: {
|
||||
eTicketId,
|
||||
oid,
|
||||
formToken,
|
||||
leftCount: num,
|
||||
},
|
||||
raw: {
|
||||
mock: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const normalized = Number(value)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return Math.floor(normalized)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { normalizeOpen91CreatePayload } from '../../open-91/payload.js'
|
||||
import { buildOpen91OutTradeNo } from '../../open-91/response.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { parseAmountToFen } from '../../../utils/money.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
|
||||
|
||||
@@ -21,6 +22,7 @@ type JsonObject = Record<string, any>
|
||||
export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObject = {}) {
|
||||
const normalized = normalizeOpen91CreatePayload(payload)
|
||||
const productNo = normalized.productNo
|
||||
const maxAmountFen = parseAmountToFen(normalized.maxAmount)
|
||||
|
||||
return {
|
||||
provider: OPEN_91_PROVIDER,
|
||||
@@ -33,7 +35,7 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
totalAmount: maxAmountFen,
|
||||
currency: 'CNY',
|
||||
paidAt: nowIso(),
|
||||
rawPayload: {
|
||||
@@ -54,11 +56,14 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo: normalized.orderNo,
|
||||
productNo,
|
||||
maxAmount: normalized.maxAmount,
|
||||
},
|
||||
snapshot: {
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo: normalized.orderNo,
|
||||
productNo,
|
||||
maxAmount: normalized.maxAmount,
|
||||
maxAmountFen,
|
||||
externalItemId: productNo,
|
||||
externalSkuCode: productNo,
|
||||
externalSkuName: productNo,
|
||||
@@ -95,7 +100,7 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
totalAmount: event.totalAmount,
|
||||
currency: 'CNY',
|
||||
rawPayloadJson,
|
||||
paidAt: existing?.paid_at || now,
|
||||
|
||||
+165
-528
@@ -3,561 +3,198 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__header) {
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__body) {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card-header--split {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.overview-file code {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
.fulfillment-simple-page {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.fulfillment-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(120px, auto);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.overview-stat :deep(.el-statistic__head) {
|
||||
margin-bottom: 6px;
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.overview-stat :deep(.el-statistic__content) {
|
||||
.metric-card strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-notes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lookup-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.lookup-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(260px, 1.4fr)
|
||||
minmax(132px, 0.36fr)
|
||||
minmax(150px, 0.44fr)
|
||||
minmax(210px, 0.5fr);
|
||||
gap: var(--space-3) var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.lookup-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lookup-field--number :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lookup-action {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.lookup-note {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.lookup-note :deep(.el-alert__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lookup-note :deep(.el-alert__title) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rule-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-enabled-control {
|
||||
.overview-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.binding-card {
|
||||
margin-top: var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__header) {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__body) {
|
||||
.fulfillment-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.binding-card--invalid {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.binding-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.binding-summary {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.binding-title-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.binding-subtle,
|
||||
.cell-subtle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cell-subtle {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cell-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.binding-collapsed-preview {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.mapping-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 74px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mapping-panel {
|
||||
min-width: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.mapping-panel :deep(.el-card__header) {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.mapping-panel :deep(.el-card__body) {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.mapping-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.mapping-panel-head strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-panel-head span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mapping-grid,
|
||||
.mapping-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.mapping-extra {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px dashed var(--border-default);
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mapping-arrow span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
min-width: 54px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.rule-editor {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.rule-editor-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rule-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rule-panel--strategy {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.rule-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rule-panel-head > span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rule-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rule-panel-title strong {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rule-step {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rule-panel--strategy .rule-step {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mapping-grid--core {
|
||||
gap: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.strategy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.24fr) minmax(150px, 0.24fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.strategy-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.advanced-fields {
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.advanced-fields summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advanced-fields summary small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.advanced-fields summary::after {
|
||||
content: '展开';
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.advanced-fields[open] summary {
|
||||
border-bottom: 1px dashed var(--border-default);
|
||||
}
|
||||
|
||||
.advanced-fields[open] summary::after {
|
||||
content: '收起';
|
||||
}
|
||||
|
||||
.advanced-grid {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.delivery-items-editor {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.delivery-items-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.delivery-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) 132px 48px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delivery-item-sku,
|
||||
.delivery-item-name,
|
||||
.delivery-item-count {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-input {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.data-table :deep(.el-table__header th) {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.data-table :deep(.el-table__body td) {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header--split,
|
||||
.rule-action-row,
|
||||
.binding-header,
|
||||
.mapping-panel-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overview-stats,
|
||||
.lookup-toolbar,
|
||||
.rule-editor-main,
|
||||
.strategy-grid,
|
||||
.mapping-editor,
|
||||
.delivery-item-row,
|
||||
.mapping-grid,
|
||||
.mapping-extra {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rule-panel-head,
|
||||
.advanced-fields summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.rule-panel-head > span,
|
||||
.advanced-fields summary small {
|
||||
text-align: left;
|
||||
.source-panel,
|
||||
.catalog-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__header),
|
||||
.section-card :deep(.el-card__body) {
|
||||
.source-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__header),
|
||||
.binding-card :deep(.el-card__body) {
|
||||
.catalog-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.section-head h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.source-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-item:hover,
|
||||
.source-item--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.source-item span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-item strong,
|
||||
.source-item small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-item strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.source-item small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.catalog-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.match-checker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.sku-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sku-name-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sku-name-cell strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sku-name-cell small {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.match-name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.fulfillment-overview,
|
||||
.fulfillment-layout,
|
||||
.match-checker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-actions,
|
||||
.catalog-toolbar {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.catalog-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
+283
-81
@@ -1,110 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
fetchAdminCloudtentaclesSkuList,
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesSessionItem,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import AdminKuaishouCloudNinetyoneSection from './components/AdminKuaishouCloudNinetyoneSection.vue'
|
||||
import AdminKuaishouCloudRuleSection from './components/AdminKuaishouCloudRuleSection.vue'
|
||||
import { useKuaishouCloudConfig } from './composables/useKuaishouCloudConfig'
|
||||
import { useKuaishouCloudNinetyone } from './composables/useKuaishouCloudNinetyone'
|
||||
import { useKuaishouCloudSku } from './composables/useKuaishouCloudSku'
|
||||
|
||||
const config = useKuaishouCloudConfig()
|
||||
const ninetyone = useKuaishouCloudNinetyone(config)
|
||||
const cloudSku = useKuaishouCloudSku(config.cloudtentaclesSourceOptions)
|
||||
|
||||
function handlePrimaryCloudSkuSelected(item: (typeof config.items.value)[number], value: number | string | undefined) {
|
||||
cloudSku.handleCloudSkuSelected(item, value)
|
||||
config.syncDeliveryItemsFromPrimaryCloudSku(item)
|
||||
type MatchState = {
|
||||
input: string
|
||||
}
|
||||
|
||||
function handleDeliveryItemCloudSkuSelected(
|
||||
item: (typeof config.items.value)[number],
|
||||
index: number,
|
||||
value: number | string | undefined,
|
||||
) {
|
||||
const deliveryItem = item.deliveryItems[index]
|
||||
if (!deliveryItem) {
|
||||
const loading = ref(true)
|
||||
const skuLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const skuErrorMessage = ref('')
|
||||
const selectedSourceKey = ref('')
|
||||
const sources = ref<AdminCloudtentaclesSourceItem[]>([])
|
||||
const sessions = ref<AdminCloudtentaclesSessionsMap>({})
|
||||
const skuItems = ref<AdminCloudtentaclesSkuItem[]>([])
|
||||
const matchState = reactive<MatchState>({
|
||||
input: '',
|
||||
})
|
||||
|
||||
const sourceRows = computed(() =>
|
||||
sources.value.map((source) => {
|
||||
const session = resolveSourceSession(source.key)
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const readySources = computed(() => sourceRows.value.filter((source) => source.ready))
|
||||
const selectedSource = computed(
|
||||
() => sourceRows.value.find((source) => source.key === selectedSourceKey.value) || null,
|
||||
)
|
||||
|
||||
const metrics = computed(() => ({
|
||||
sourceCount: sources.value.length,
|
||||
readySourceCount: readySources.value.length,
|
||||
skuCount: skuItems.value.length,
|
||||
inventoryTotal: skuItems.value.reduce((sum, item) => sum + Math.max(0, Number(item.inventory || 0)), 0),
|
||||
}))
|
||||
|
||||
const matchedSku = computed(() => {
|
||||
const normalizedInput = normalizeMatchName(matchState.input)
|
||||
if (!normalizedInput) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
skuItems.value.find((item) => String(item.name || '').trim() === matchState.input.trim()) ||
|
||||
skuItems.value.find((item) => normalizeMatchName(item.name) === normalizedInput) ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
watch(selectedSourceKey, (sourceKey) => {
|
||||
if (sourceKey) {
|
||||
void loadSkuList(sourceKey)
|
||||
} else {
|
||||
skuItems.value = []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadPage)
|
||||
|
||||
async function loadPage() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
cloudSku.handleDeliveryItemCloudSkuSelected(item, deliveryItem, value)
|
||||
config.syncPrimaryCloudSkuFromDeliveryItems(item)
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSourceConfig()
|
||||
sources.value = Array.isArray(response.data.sources) ? response.data.sources : []
|
||||
sessions.value = response.data.sessions || {}
|
||||
selectedSourceKey.value = resolveDefaultSourceKey()
|
||||
|
||||
if (!selectedSourceKey.value) {
|
||||
skuItems.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 账号失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(config.loadConfigs)
|
||||
async function loadSkuList(sourceKey = selectedSourceKey.value) {
|
||||
if (!sourceKey) {
|
||||
skuItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
skuLoading.value = true
|
||||
skuErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
|
||||
skuItems.value = Array.isArray(response.data.items) ? response.data.items : []
|
||||
} catch (error) {
|
||||
skuItems.value = []
|
||||
skuErrorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 商品失败'
|
||||
} finally {
|
||||
skuLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
void loadPage()
|
||||
}
|
||||
|
||||
function resolveDefaultSourceKey() {
|
||||
const current = sourceRows.value.find((source) => source.key === selectedSourceKey.value)
|
||||
if (current?.ready) {
|
||||
return current.key
|
||||
}
|
||||
|
||||
return readySources.value[0]?.key || sourceRows.value[0]?.key || ''
|
||||
}
|
||||
|
||||
function resolveSourceSession(sourceKey: string): AdminCloudtentaclesSessionItem | null {
|
||||
return sessions.value[sourceKey] || null
|
||||
}
|
||||
|
||||
function normalizeMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: AdminCloudtentaclesSourceItem) {
|
||||
return source.label || source.username || source.key
|
||||
}
|
||||
|
||||
function formatCloudPrice(value: unknown) {
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) ? String(price) : '-'
|
||||
}
|
||||
|
||||
function getSourceTagType(source: { ready: boolean; enabled?: boolean }) {
|
||||
if (source.ready) return 'success'
|
||||
if (source.enabled === false) return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function getSourceStatusText(source: { ready: boolean; enabled?: boolean; session: AdminCloudtentaclesSessionItem | null }) {
|
||||
if (source.ready) return '可用'
|
||||
if (source.enabled === false) return '已停用'
|
||||
if (!source.session?.hasToken) return '未登录'
|
||||
return '待检查'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以维护新履约配置" />
|
||||
<section class="admin-panel fulfillment-simple-page">
|
||||
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以查看履约配置" />
|
||||
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="config.errorMessage.value"
|
||||
:title="config.errorMessage.value"
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-skeleton v-if="config.loading.value" :rows="12" animated />
|
||||
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
|
||||
<template v-else>
|
||||
<AdminKuaishouCloudNinetyoneSection
|
||||
:ninetyone-lookup-loading="ninetyone.ninetyoneLookupLoading.value"
|
||||
:ninetyone-lookup-error-message="ninetyone.ninetyoneLookupErrorMessage.value"
|
||||
:ninetyone-lookup-results="ninetyone.ninetyoneLookupResults.value"
|
||||
:ninetyone-lookup-metrics="ninetyone.ninetyoneLookupMetrics.value"
|
||||
:ninetyone-lookup-form="ninetyone.ninetyoneLookupForm"
|
||||
:is-imported="ninetyone.isNinetyoneProductImported"
|
||||
@lookup="ninetyone.lookupNinetyoneProducts()"
|
||||
@import-product="ninetyone.importNinetyoneProduct($event)"
|
||||
<section class="fulfillment-overview">
|
||||
<div class="metric-card">
|
||||
<span>履约账号</span>
|
||||
<strong>{{ metrics.readySourceCount }} / {{ metrics.sourceCount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>可发商品</span>
|
||||
<strong>{{ metrics.skuCount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>总库存</span>
|
||||
<strong>{{ metrics.inventoryTotal }}</strong>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button :loading="loading || skuLoading" @click="refreshAll">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fulfillment-layout">
|
||||
<aside class="source-panel">
|
||||
<div class="section-head">
|
||||
<h3>cloudtentacles 账号</h3>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="sourceRows.length === 0" description="暂无账号,请先到平台店铺配置登录。" />
|
||||
|
||||
<div v-else class="source-list">
|
||||
<button
|
||||
v-for="source in sourceRows"
|
||||
:key="source.key"
|
||||
type="button"
|
||||
class="source-item"
|
||||
:class="{ 'source-item--active': source.key === selectedSourceKey }"
|
||||
@click="selectedSourceKey = source.key"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ formatSourceLabel(source) }}</strong>
|
||||
<small>{{ source.username || source.key }}</small>
|
||||
</span>
|
||||
<el-tag :type="getSourceTagType(source)" effect="light">
|
||||
{{ getSourceStatusText(source) }}
|
||||
</el-tag>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="catalog-panel">
|
||||
<div class="catalog-toolbar">
|
||||
<div class="section-head">
|
||||
<h3>商品列表与匹配状态</h3>
|
||||
<p v-if="selectedSource">
|
||||
当前账号:{{ formatSourceLabel(selectedSource) }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button :loading="skuLoading" :disabled="!selectedSourceKey" @click="loadSkuList()">
|
||||
刷新商品
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="skuErrorMessage"
|
||||
:title="skuErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<AdminKuaishouCloudRuleSection
|
||||
:enabled="config.enabled.value"
|
||||
:saving="config.saving.value"
|
||||
:cloudtentacles-source-options="config.cloudtentaclesSourceOptions.value"
|
||||
:cloud-sku-catalog-loading="cloudSku.cloudSkuCatalogLoading.value"
|
||||
:validation-state="config.validationState.value"
|
||||
:items="config.items.value"
|
||||
:filtered-items="config.filteredItems.value"
|
||||
:rule-filter="config.ruleFilter.value"
|
||||
:rule-filter-options="config.ruleFilterOptions.value"
|
||||
:is-collapsed="config.isCollapsed"
|
||||
:is-item-complete="config.isItemComplete"
|
||||
:get-card-title="config.getCardTitle"
|
||||
:get-card-summary="config.getCardSummary"
|
||||
:get-rule-state="config.getRuleState"
|
||||
:get-external-match-summary="config.getExternalMatchSummary"
|
||||
:get-cloud-source-summary="config.getCloudSourceSummary"
|
||||
:get-cloud-sku-summary="config.getCloudSkuSummary"
|
||||
:get-consume-shop-summary="config.getConsumeShopSummary"
|
||||
:get-delivery-plan-summary="config.getDeliveryPlanSummary"
|
||||
:get-cloud-sku-options="cloudSku.getCloudSkuOptions"
|
||||
:format-cloud-sku-option-label="cloudSku.formatCloudSkuOptionLabel"
|
||||
:get-kuaishou-consume-shop-options="config.getKuaishouConsumeShopOptions"
|
||||
:format-kuaishou-consume-shop-option="config.formatKuaishouConsumeShopOption"
|
||||
:cloud-sku-catalog-error-message="cloudSku.cloudSkuCatalogErrorMessage.value"
|
||||
@update:enabled="config.enabled.value = $event"
|
||||
@update:rule-filter="config.ruleFilter.value = $event"
|
||||
@expand-all="config.expandAllRules()"
|
||||
@collapse-ready="config.collapseReadyRules()"
|
||||
@refresh-cloud-sku="cloudSku.refreshCloudSkuCatalog()"
|
||||
@add-item="config.addItem()"
|
||||
@save-configs="config.saveConfigs()"
|
||||
@toggle-collapsed="config.toggleCollapsed($event)"
|
||||
@handle-cloud-sku-selected="handlePrimaryCloudSkuSelected"
|
||||
@handle-delivery-item-cloud-sku-selected="handleDeliveryItemCloudSkuSelected"
|
||||
@handle-cloud-sku-dropdown-visible="
|
||||
(item, visible) => cloudSku.handleCloudSkuDropdownVisible(item, visible)
|
||||
"
|
||||
@handle-kuaishou-consume-shop-selected="config.handleKuaishouConsumeShopSelected($event)"
|
||||
@handle-cloud-source-keys-changed="config.handleCloudSourceKeysChanged($event)"
|
||||
@add-delivery-item="config.addDeliveryItem($event)"
|
||||
@remove-delivery-item="(item, index) => config.removeDeliveryItem(item, index)"
|
||||
@remove-item="config.removeItem($event)"
|
||||
<div class="match-checker">
|
||||
<el-input
|
||||
v-model="matchState.input"
|
||||
clearable
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看会命中哪个 cloudtentacles 商品"
|
||||
/>
|
||||
<el-tag v-if="matchedSku" type="success" effect="light">
|
||||
命中 #{{ matchedSku.id }} {{ matchedSku.name }}
|
||||
</el-tag>
|
||||
<el-tag v-else-if="matchState.input.trim()" type="warning" effect="light">
|
||||
未命中
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="skuLoading"
|
||||
:data="skuItems"
|
||||
border
|
||||
stripe
|
||||
class="sku-table"
|
||||
empty-text="当前账号暂无可展示商品"
|
||||
>
|
||||
<el-table-column label="cloudtentacles 商品" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="sku-name-cell">
|
||||
<strong>{{ row.name || '-' }}</strong>
|
||||
<small>#{{ row.id }} · {{ row.description || row.name || '-' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="91 自动匹配名" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<code class="match-name">{{ row.name || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="120">
|
||||
<template #default="{ row }">{{ formatCloudPrice(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="inventory" width="120" sortable />
|
||||
<el-table-column label="发货限制" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.buyLimitMin || 1 }} - {{ row.buyLimitMax || 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="Number(row.inventory || 0) > 0 ? 'success' : 'danger'" effect="light">
|
||||
{{ Number(row.inventory || 0) > 0 ? '可履约' : '无库存' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
查询SKU列表
|
||||
```
|
||||
{
|
||||
"baseUrl": "https://123.207.217.176",
|
||||
"itemCount": 12,
|
||||
"items": [
|
||||
{
|
||||
"id": 5,
|
||||
"categoriesId": 2,
|
||||
"name": "黑色高级特训官上衣",
|
||||
"description": "黑色高级特训官上衣",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023027lsvnRuLH.jpg",
|
||||
"inventory": 99980,
|
||||
"price": 360,
|
||||
"listingTime": "2024-09-01 00:00:00",
|
||||
"delistingTime": "2027-03-07 00:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 5,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023027lsvnRuLH.jpg",
|
||||
"inventory": 99980,
|
||||
"desc": "黑色高级特训官上衣",
|
||||
"name": "黑色高级特训官上衣",
|
||||
"price": 360,
|
||||
"listing_time": "2024-09-01 00:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"categoriesId": 2,
|
||||
"name": "套装-暗影哥特",
|
||||
"description": "套装-暗影哥特",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023032PrIv36BU.jpg",
|
||||
"inventory": 68931,
|
||||
"price": 888,
|
||||
"listingTime": "2024-09-01 00:00:00",
|
||||
"delistingTime": "2027-03-07 00:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 6,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023032PrIv36BU.jpg",
|
||||
"inventory": 68931,
|
||||
"desc": "套装-暗影哥特",
|
||||
"name": "套装-暗影哥特",
|
||||
"price": 888,
|
||||
"listing_time": "2024-09-01 00:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"categoriesId": 2,
|
||||
"name": "套装-浪漫天命",
|
||||
"description": "浪漫天命",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023123Ya74vO4m.jpg",
|
||||
"inventory": 99864,
|
||||
"price": 360,
|
||||
"listingTime": "2024-10-25 10:00:00",
|
||||
"delistingTime": "2027-03-07 00:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 28,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023123Ya74vO4m.jpg",
|
||||
"inventory": 99864,
|
||||
"desc": "浪漫天命",
|
||||
"name": "套装-浪漫天命",
|
||||
"price": 360,
|
||||
"listing_time": "2024-10-25 10:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"categoriesId": 2,
|
||||
"name": "荣耀勋章礼包(2个)",
|
||||
"description": "内含兵团币*2,荣耀勋章*2",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020917cKIUIINT.jpg",
|
||||
"inventory": 11732,
|
||||
"price": 20,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 73,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020917cKIUIINT.jpg",
|
||||
"inventory": 11732,
|
||||
"desc": "内含兵团币*2,荣耀勋章*2",
|
||||
"name": "荣耀勋章礼包(2个)",
|
||||
"price": 20,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"categoriesId": 2,
|
||||
"name": "荣耀勋章礼包(30个)",
|
||||
"description": "内含兵团币*30,荣耀勋章*30",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020909DugrntmJ.jpg",
|
||||
"inventory": 13837,
|
||||
"price": 300,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 74,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020909DugrntmJ.jpg",
|
||||
"inventory": 13837,
|
||||
"desc": "内含兵团币*30,荣耀勋章*30",
|
||||
"name": "荣耀勋章礼包(30个)",
|
||||
"price": 300,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"categoriesId": 2,
|
||||
"name": "荣耀勋章礼包(90个)",
|
||||
"description": "内含兵团币*90,荣耀勋章*90",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020901T8is3SCH.jpg",
|
||||
"inventory": 6226,
|
||||
"price": 900,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 75,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020901T8is3SCH.jpg",
|
||||
"inventory": 6226,
|
||||
"desc": "内含兵团币*90,荣耀勋章*90",
|
||||
"name": "荣耀勋章礼包(90个)",
|
||||
"price": 900,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"categoriesId": 2,
|
||||
"name": "幸运币礼包(2个)",
|
||||
"description": "内含兵团币*2,幸运币*2",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023255pOfQub1o.jpg",
|
||||
"inventory": 18083,
|
||||
"price": 20,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 76,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023255pOfQub1o.jpg",
|
||||
"inventory": 18083,
|
||||
"desc": "内含兵团币*2,幸运币*2",
|
||||
"name": "幸运币礼包(2个)",
|
||||
"price": 20,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"categoriesId": 2,
|
||||
"name": "幸运币礼包(30个)",
|
||||
"description": "内含兵团币*30,幸运币*30",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-202504090233039wDQ74pX.jpg",
|
||||
"inventory": 74908,
|
||||
"price": 300,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 77,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-202504090233039wDQ74pX.jpg",
|
||||
"inventory": 74908,
|
||||
"desc": "内含兵团币*30,幸运币*30",
|
||||
"name": "幸运币礼包(30个)",
|
||||
"price": 300,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"categoriesId": 2,
|
||||
"name": "幸运币礼包(90个)",
|
||||
"description": "内含兵团币*90,幸运币*90",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023307lVWXch1A.jpg",
|
||||
"inventory": 22246,
|
||||
"price": 900,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 78,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023307lVWXch1A.jpg",
|
||||
"inventory": 22246,
|
||||
"desc": "内含兵团币*90,幸运币*90",
|
||||
"name": "幸运币礼包(90个)",
|
||||
"price": 900,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"categoriesId": 2,
|
||||
"name": "套装-花面小丑",
|
||||
"description": "套装-花面小丑",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022627fSb8meAR.jpg",
|
||||
"inventory": 80,
|
||||
"price": 2000,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 79,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022627fSb8meAR.jpg",
|
||||
"inventory": 80,
|
||||
"desc": "套装-花面小丑",
|
||||
"name": "套装-花面小丑",
|
||||
"price": 2000,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"categoriesId": 2,
|
||||
"name": "套装-沙丘之歌",
|
||||
"description": "套装-沙丘之歌",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023851ToPSnZUa.jpg",
|
||||
"inventory": 2857,
|
||||
"price": 1020,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 80,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023851ToPSnZUa.jpg",
|
||||
"inventory": 2857,
|
||||
"desc": "套装-沙丘之歌",
|
||||
"name": "套装-沙丘之歌",
|
||||
"price": 1020,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"categoriesId": 2,
|
||||
"name": "龙宫之主大礼包",
|
||||
"description": "龙宫之主大礼包",
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022640Bp9rS1nl.jpg",
|
||||
"inventory": 9873,
|
||||
"price": 8000,
|
||||
"listingTime": "2025-03-07 10:00:00",
|
||||
"delistingTime": "2027-03-07 10:00:00",
|
||||
"buyLimitMin": 1,
|
||||
"buyLimitMax": 1,
|
||||
"raw": {
|
||||
"id": 81,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022640Bp9rS1nl.jpg",
|
||||
"inventory": 9873,
|
||||
"desc": "龙宫之主大礼包",
|
||||
"name": "龙宫之主大礼包",
|
||||
"price": 8000,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"rawItems": [
|
||||
{
|
||||
"id": 5,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023027lsvnRuLH.jpg",
|
||||
"inventory": 99980,
|
||||
"desc": "黑色高级特训官上衣",
|
||||
"name": "黑色高级特训官上衣",
|
||||
"price": 360,
|
||||
"listing_time": "2024-09-01 00:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023032PrIv36BU.jpg",
|
||||
"inventory": 68931,
|
||||
"desc": "套装-暗影哥特",
|
||||
"name": "套装-暗影哥特",
|
||||
"price": 888,
|
||||
"listing_time": "2024-09-01 00:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023123Ya74vO4m.jpg",
|
||||
"inventory": 99864,
|
||||
"desc": "浪漫天命",
|
||||
"name": "套装-浪漫天命",
|
||||
"price": 360,
|
||||
"listing_time": "2024-10-25 10:00:00",
|
||||
"delisting_time": "2027-03-07 00:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020917cKIUIINT.jpg",
|
||||
"inventory": 11732,
|
||||
"desc": "内含兵团币*2,荣耀勋章*2",
|
||||
"name": "荣耀勋章礼包(2个)",
|
||||
"price": 20,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020909DugrntmJ.jpg",
|
||||
"inventory": 13837,
|
||||
"desc": "内含兵团币*30,荣耀勋章*30",
|
||||
"name": "荣耀勋章礼包(30个)",
|
||||
"price": 300,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250315020901T8is3SCH.jpg",
|
||||
"inventory": 6226,
|
||||
"desc": "内含兵团币*90,荣耀勋章*90",
|
||||
"name": "荣耀勋章礼包(90个)",
|
||||
"price": 900,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023255pOfQub1o.jpg",
|
||||
"inventory": 18083,
|
||||
"desc": "内含兵团币*2,幸运币*2",
|
||||
"name": "幸运币礼包(2个)",
|
||||
"price": 20,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-202504090233039wDQ74pX.jpg",
|
||||
"inventory": 74908,
|
||||
"desc": "内含兵团币*30,幸运币*30",
|
||||
"name": "幸运币礼包(30个)",
|
||||
"price": 300,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023307lVWXch1A.jpg",
|
||||
"inventory": 22246,
|
||||
"desc": "内含兵团币*90,幸运币*90",
|
||||
"name": "幸运币礼包(90个)",
|
||||
"price": 900,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022627fSb8meAR.jpg",
|
||||
"inventory": 80,
|
||||
"desc": "套装-花面小丑",
|
||||
"name": "套装-花面小丑",
|
||||
"price": 2000,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409023851ToPSnZUa.jpg",
|
||||
"inventory": 2857,
|
||||
"desc": "套装-沙丘之歌",
|
||||
"name": "套装-沙丘之歌",
|
||||
"price": 1020,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"categories_id": 2,
|
||||
"image": "https://gp.playinjoy.com/campus/hpjy/upload/jpg/ycj-20250409022640Bp9rS1nl.jpg",
|
||||
"inventory": 9873,
|
||||
"desc": "龙宫之主大礼包",
|
||||
"name": "龙宫之主大礼包",
|
||||
"price": 8000,
|
||||
"listing_time": "2025-03-07 10:00:00",
|
||||
"delisting_time": "2027-03-07 10:00:00",
|
||||
"buy_limit_min": 1,
|
||||
"buy_limit_max": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
这个是平台可以发货的列表
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"body": {
|
||||
"sign": "0AB1463E47F4DB10BF530E1931F34AF9",
|
||||
"buyNum": 1,
|
||||
"orderNo": "2614700005853561",
|
||||
"version": "1.0",
|
||||
"maxAmount": "0.01",
|
||||
"productNo": "测试-关联商品1",
|
||||
"timestamp": 1779853484,
|
||||
"callbackUrl": ""
|
||||
},
|
||||
"source": "91kaquan",
|
||||
"pendingAt": "2026-05-27 11:44:43",
|
||||
"receivedAt": "2026-05-27 11:44:43",
|
||||
"pendingReason": "unconfigured_items"
|
||||
}
|
||||
|
||||
|
||||
这是91 卡券发送来的查询信息, 可以获取 商品名字, 订单号 orderNo 和 最大允许金额(可以看作正常金额) maxAmount 和时间等等
|
||||
这是最重要的信息
|
||||
|
||||
Reference in New Issue
Block a user